summaryrefslogtreecommitdiff
path: root/docs/design.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design.typ')
-rw-r--r--docs/design.typ2071
1 files changed, 0 insertions, 2071 deletions
diff --git a/docs/design.typ b/docs/design.typ
deleted file mode 100644
index 80c8bb49..00000000
--- a/docs/design.typ
+++ /dev/null
@@ -1,2071 +0,0 @@
-// docs/design.pdf is a retained test fixture, not regenerated by normal builds.
-#import "@preview/cetz:0.5.1"
-
-#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")
-
-#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)
-
-#let wide(caption: none, body) = place(
- top,
- scope: "parent",
- float: true,
- clearance: 1.2em,
- figure(body, caption: caption),
-)
-
-#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
- optional function pointers, with in-process fallbacks,
- 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
-
-== The acme inheritance
-
-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.
-
-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/fs.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.
-
-*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 sibling `05-zig-p4` toolchain 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 `host_io.Shell`'s OSC 133 startup snippets, but not their
-files. Each host owns a private `mkstemp` pair for its lifetime, writes and
-closes both before the first fork, passes those unpredictable paths directly
-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.
-
-== 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. Local and detached sessions both
-serve 9P by default. 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_io.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` --- optional callbacks])
- content((8.8, -0.32), text(6.0pt)[one host owns each core])
- content((8.8, -0.68), text(6.0pt)[null callbacks use core fallbacks])
- 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_io.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>
-
-Eighteen arms (`pardes.Effect`). The core never performs IO for any of
-them; it asks.
-
-```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: filesystem.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 in
-`src/file_watch.zig`, with Linux inotify or a macOS kqueue behind one
-mark/reconcile transaction; the native macOS host watches with debounced
-DispatchSources instead, then restats the exact path under a pane-generation
-guard. All three macOS watchers mark the same two things for the same reason:
-the file catches in-place writes while the parent directory follows rename-over
-saves, and the file mark is re-armed once such a save has moved the inode.
-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;
-}
-
-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.
-
-== `Host.VTable`: who serves the core
-
-`host_io.Host` holds a context pointer and optional callbacks. macOS enters
-its native shell through the separate `Runtime` C ABI.
-
-```zig
-pub const VTable = struct {
- /// The ONLY place the process may sleep.
- wait_input: ?*const fn (
- ctx: ?*anyopaque,
- timeout_ms: u32,
- ) void = null,
- present: ?*const fn (
- ctx: ?*anyopaque,
- surface: *const pardes.Surface,
- ) void = null,
- // ...
- tty_taken: ?*const fn (
- ctx: ?*anyopaque,
- pane: u8,
- ) bool = null,
- // ...
-};
-```
-
-A null method is not an error: the core substitutes a default backed by ordinary
-data structures in the same process (`host_io.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 cores keep independent state.
-
-The fallback filesystem is not empty. It is pardes's own source, embedded
-(`src/fs.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.
-
-=== Callback ownership
-
-Each core has one host. The detached host explicitly broadcasts shared state
-and routes clipboard reads and link opening to the originating frontend.
-There is no name-based fan-out layer.
-
-== 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.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.poll_frame) |f| f(h.ctx);
- _ = p.frame_arena.reset(.retain_capacity);
- const surface = try p.render(
- p.frame_arena.allocator());
- if (v.present) |f| f(h.ctx, surface);
- if (v.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. `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`.
-
-`post_present` is split from `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 (in `exec.zig`) 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.
-
-Each pane kind keeps its storage and operations together in its own file
-(`File.zig`, `terminal.zig`, `Output.zig`, `mini.zig`, `pdf_view.zig`, and the
-image pane in `image.zig`), reached through `panes.zig` beside `Pane`.
-`layout.zig` owns placement and presentation state. `pardes.zig` handles input
-and cross-pane state directly, without a pane vtable; the rest of the editor
-is split by thing as acme is: `Text.zig`, `edit.zig`, `normal.zig`, `tagline.zig`,
-`look.zig`, `exec.zig`, `mouse.zig`, `Messages.zig`, `Pipe.zig`, `colors.zig`,
-`surface.zig`, `body_layer.zig`, `Layer.zig` and `dump.zig`. Integration fixtures live
-in `test/panes.zig`, `test/output.zig`, and `test/pdf.zig`.
-
-= State
-
-== One struct, fixed where it can be
-
-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_panes[6][16], col_n[6]
- panes: [16]?*Pane
- active + drag + config.Runtime
- rects: [16]layout.Rect
- presentation: layout.Presentation
- effects: [limits.effect_cap]Effect + head/len
- in_q: [64]Event + head/len
- fallback: host_io.Fallback
- fs: fs.Namespace
-
-Pane
- serial + mode + vweight
- terminal: ?*panes.Terminal.State
- file: ?panes.File.State
- image: ?panes.Image.State
- pdf: PdfSlot
- cwd: none | inherited(*Pane) | owned([]u8)
- tag_tail + prompt + cursor + selections
- ovl: ?panes.Terminal.EditBuffer
-
-Terminal.State = VT + stream + replay + reply
-File.State = path + bytes + line index + syntax + undo
-Image.State = decoded pixels + render cache
-Pdf.State = document + layout + raster cache + search
-```
-
-`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
-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.
-
-`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 `config.Runtime`: booleans,
-theme index, owned bounded shell/font strings, requested/effective font facts,
-one panel-transition enum, and scene-effect booleans
-(`config.Runtime`). A compile-time `settings` array
-(`config.Runtime.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.
-
-== 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 (`mouse.Drag`): `none`,
-`border_v`, `border_h`, `move`, `column_move`, `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 text
-
-A tag is a `Text` (`Text.zig`), as a body is: acme's `Text`, one per
-`what` — the body, the pane's tag, the prompt line, a column's tag and the
-workspace tag. Each holds its own cursor, selections, mode and undo, so tags
-are edited by the same `edit.zig` and `normal.zig` code as bodies, and the one
-key they do not share is `:`, which moves between a pane's body and its tag.
-A tag holds any number of lines; a pane's tag takes a row per line up to
-`MAX_TAG_ROWS`, each drawn as a tag layer of its own.
-
-A pane's tag shows a live prefix (path, dirty marker, PDF page) before the text
-it owns, and that prefix is computed at every read, never stored
-(`tagline.tagPrefix`). Everything sees the whole line as shown, prefix ++
-text: render, the mouse, Look, Exec, the 9P `tag` file and the keyboard,
-whose motions reach the path as acme's do. The prefix is read-only: an edit
-installs only what follows it, and one that would change it is refused
-(`edit.setEditText`, `Text.refused`). Because the prefix is computed, sync
-moves the tag's positions with it when its length changes (`Pane.tag_lead`).
-Editing the path is a separate draft (`Pane.prompt = .name`) committed by
-Enter, since a buffer's name is not text it owns; a click or a key typed
-into it starts one.
-
-The tag's text is bounded by `limits.max_tag_tail`: the 9P `tag` file and a
-dump reader refuse input that does not fit rather than truncating it.
-
-= Diffing and presenting a frame
-
-== Eleven transitions
-
-`layout.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`
-(`layout.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.
-
-== 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
-(`layout.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 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. The
-scene `Crt` is one full-window pass in each native GUI; the SDL GUI's post
-chain also runs Shadertoy files (`Shader`).
-
-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>` lists the current backend's build-embedded source paths
-under `/virtual`. Look opens each full file without a source checkout.
-Shared implementations share paths.
-
-== 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
-// 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;
- }
-}
-```
-
-`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 `panes.File.fitEnd` (`panes.File.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.
-
-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: `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 `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.
-
-The detached core also serves the default 9P socket. Its event loop drains
-filesystem transactions alongside frontend and terminal input.
-
-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,
-};
-```
-
-`wire.ClientTag` and `wire.ServerTag`. Eleven tags in, eight out, and the shape of both
-lists is the argument.
-
-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.
-
-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.
-
-`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.
-
-Host polling, presentation, process queries and worker dispatch stay local to
-the session owner. They are not frontend wire messages. The owner also serves
-9P: each filesystem reply returns to its requesting connection, not to attached
-frontends. `Event.fs_req` therefore 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: both ends are
-pardes, and an unknown tag is not a valid message in this protocol version.
-
-`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 sibling `05-zig-p4` toolchain 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.
-
-Historically, it was a module exposed through `build.zig.zon`. 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
-was possible because `zig_p4` declared no dependencies of its own. The current
-editor build no longer imports that package; the firmware toolchain is separate.
-
-The C ABI carries terminal bytes. After building the object here, `zig build
--Dpardes` in `../05-zig-p4` links `src/esp32p4/app.zig` into the firmware image.
-That toolchain needs ESP-IDF register headers; the local object build does not.
-The standalone GPIO 9P image instead uses `zig build
--Dapp=../02-pardes-code/src/esp32p4_9p.zig` there. It does not link the editor:
-`src/esp32p4_gpio.zig` holds its fixed namespace and `src/esp32p4_9p.zig` drives
-the UART protocol loop.
-
-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/memory.zig`'s `limits` contains the board-shaped capacities. 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.
-
-The original budget table below is historical; `memory.limits` is authoritative.
-
-```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`,
-`builtins.Board.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
-`builtins.Board.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
-`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.
-
-`builtins.Board` 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
-(`builtins.Board.enabled`).
-
-= The control filesystem <fs>
-
-== Namespace and transactions
-
-`src/fs.zig` owns Look resolution and the editor's file interface. An ordinary
-Look checks the OS first, then the virtual tree. `/n/os` and `/n/self` select
-those mounts explicitly; `/virtual` names the embedded and self-reflecting tree.
-Named remote mounts live under `/n/<name>` and retain their identity through Save.
-
-Every native session opens a 9P2000 Unix socket. The wire root exposes `os` and
-`self`, without the editor's `/n` prefix. `self/pane/<serial>` contains `body`,
-`tag`, `ctl`, `addr`, `data`, `event`, and selection files. Offsets are UTF-8
-bytes. `self/screen` freezes rendered cells and styles for the lifetime of an open.
-See `docs/fs.md` for the public paths and commands.
-
-`src/9p.zig` implements the protocol without OS dependencies; `src/9p_io.zig`
-owns native sockets and the client. Requests enter through `Event.fs_req`, and
-`Effect.fs_reply` carries replies. A pending event read returns `Status.again`;
-the native listener owns waiting and retries. Filesystem mutation runs on the
-same thread as editing.
-
-== Nested Look
-
-Pane shells inherit `PARDES_PID` (the editor's process id), `PARDES_9P` and
-`PARDES_PANE` (the pane serial). The first answers whether the shell is inside
-a pardes at all, the other two answer how to reach it, and they are separate
-because a session whose listener never came up still owns its children. A child
-launch that finds a live `PARDES_PID` and no way to reach it says so instead of
-starting a second editor. Otherwise it resolves its OS-relative argument in the
-child's working directory, then writes `look <path>` to the parent's
-`pane/<serial>/ctl`. Explicit `/virtual` and `/n` paths resolve in the parent.
-The ordinary filesystem update performs layout and drains host effects.
-
-`--nested` starts a separate editor and withholds `PARDES_PID` from its direct
-pane shells, which is the whole of the opt-out. Its 9P socket remains available
-for control and plugins, so those shells still get `PARDES_9P` and
-`PARDES_PANE`. There is no executable-name discovery or separate Look listener.
-
-Detached frontends use `pardes-detached-<name>.sock` in the same runtime
-directory. The shared Unix socket conventions live in `src/9p_io.zig`.
-
-= 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`; the sibling `05-zig-p4` toolchain builds the firmware image])
- 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, HarfBuzz (the SDL shell's text shaping, lazy,
-built against that 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) — all pinned through `zig fetch` and wired in `build.zig`. The board's
-`05-zig-p4` firmware toolchain is a separate sibling checkout.
-
-The 9P library is the one pinned dependency that is not third-party: `cloud9`
-lives at `git.sr.ht/~gbrls/cloud9`, pushed as `[email protected]:~gbrls/cloud9` and
-fetched from that commit's HTTPS URL, since zig has no `git+ssh` support.
-
-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 (`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.
-
-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.
-
-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. `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 — 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.
-
-== 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` 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). In the sibling `05-zig-p4` toolchain, `zig build selftest` flashes and
-runs `src/esp32p4/selftest.zig` on real hardware. The local freestanding object
-and the standalone GPIO 9P image can both be compiled without a board attached.
-
-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).
-
-= 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, 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 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.
-
-#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 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
-per column); global topbar;
-per-pane gutter (move box + scrollbar) and tag row; `splitBelow` shrinks only
-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
-builtin as SPC j j);
-`--tty` single-pane mode.
-
-*Mouse.* Left: select (block, stays highlighted after drag, pins cursor,
-enters normal), click clears, tag-row click enters tag edit, scrollbar
-click scrolls up-to-row (right button: down), border/move drags. Middle:
-execute — no-drag expands to file-ish word (alnum `.-+/:@_~`); builtin or send
-to shell; does not focus. Right: look — peel `:NNN`, resolve against the
-clicked pane's dir (`/proc/pid/cwd`, or a file's dirname) and, if that fails,
-against every other live pane's, most-recently-focused first (the jump stack
-backwards, duplicate dirs skipped; absolute words try one dir and stop) so a
-relative name is openable from any window that can see it; dir → shell + `ls`
-in source column (dedup by cwd), file → file pane at line (dedup by path,
-first doc opens left column and may evict a lone pristine shell; later docs
-split below the existing doc — an output pane, `+Search`/`+Help`, is not a
-doc for either half of that rule: it never claims a column, it splits below
-whatever pane asked for it, and nothing splits from it), image ext → image
-pane, `.pdf` → PDF pane. Ctrl+left is goto-definition, the one chord borrowed
-from every editor with a language backend. Two spellings the word expansion
-has beyond a path: `` @`ls -la` `` is taken WHOLE and runs as a command rather
-than opening as a file, and `@p7:10:5` addresses a live pane by number for the
-things — terminals, output buffers — that have no path to name.
-Middle+left chord: kept
-left selection appended as trailing CLI argument. A stationary pointer gets a
-delayed, theme-derived highlight of the exact side-effect-free selection that
-Look would expand; it neither focuses nor installs that selection, and pointer
-leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched.
-
-*Modes.* normal: helix motions (`h j k l w b e W B E 0 gl ^`, `f F t T` and
-`Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`),
-insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth
-mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS
-up to 64 (`C`/`Alt-C` copy, `s`/`S` select and split by regex with a live
-preview, `Alt-s` split on newline, `,`/`Alt-,` keep and remove the primary,
-`)`/`(` rotate, `Alt--`/`Alt-_` merge), operators (`d c y p P R u U`, `Alt-d`
-delete-noyank, `J`, `>`/`<`, `~`/`` ` ``/`` Alt-` ``, `Ctrl-a`/`Ctrl-x`,
-`Ctrl-c` comment-toggle), textobjects and surrounds under `m`
-(`mm mi ma ms mr md`), the `]`/`[` pairs (`]p ]d ]D ]<space>`), `/` with `n`/`N`,
-`|` to filter the selection through a command, `:` for the tag as a command
-line, and Enter=look Tab=execute at cursor. Since the helix motion model
-landed, a traversal motion SELECTS the range it crossed — which is why there is
-no verb+noun grammar and why `i` after `w` types at the selection's start. insert:
-click-and-type; terminals get splice runs (shift right, never overwrite;
-absolute-row anchored), files get real edits. tty: raw pty forwarding
-(Ctrl-key toggle, default Ctrl-b, `--tty-toggle`), mouse still usable,
-promptClickMove on entry, prompts visible (hidden in the other modes via
-OSC 133). `y` fills the yank register and `p` pastes it; neither touches the
-system clipboard, which is helix's five words — `SPC y/Y/p/P/R`, out via
-`set_clipboard` and back via `read_clipboard` — and nothing else, so a delete
-cannot clobber what the desktop was holding. A paste from an outer terminal
-arrives bracketed, as one `paste` event.
-
-*Tag.* Compact name/status prefix + editable command tail. File names support
-staged edits: Enter commits a new buffer save target, Escape cancels, and no
-disk rename or write happens until explicit Save. Terminal cwd and image/PDF
-status stay generated. Save leads the tail of every pane holding text of its own:
-`Save Tty Collapse Del` for a file or an output buffer,
-`Tty Save Mode Filter Collapse Del` for a terminal, and `Tty Collapse Del`
-for an image. PDFs use `Tty PdfSections PdfTint Collapse Del`, without tint status text.
-The word that closes the thing is last on every tagline, so overshooting the
-click before it cannot destroy anything.
-`Collapse` toggles a pane between its tagline alone and its expanded height;
-hidden body contents and running terminals are retained.
-An unsaved file has `*` after its name; an image tag
-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:
-`Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill` —
-editable by left click, with keyboard command navigation and Exec/Look gestures.
-An additional editable tag in each column supplies local New, Tty, Find,
-Grep and Joincol commands; it is always shown, as acme's column tag is.
-Colors and Crt left it for their leader paths.
-
-*Panes.* Terminal: ghostty-vt, 16 MiB scrollback, OSC 133 prompt semantics,
-OSC 7 cwd, DSR/DA/kitty-query replies (write_pty + device_attributes — the
-nushell/helix regression), a default-on pane-local Filter which uses ghostty-vt's
-theme-derived 256-colour generation and keys rendered cell foreground/background
-truecolour and OSC overrides through that palette without mutating emulator state,
-greeting `ls`, auto-follow output unless
-navigating. File: line-number gutter (fixed width), tree-sitter highlights
-(c/cpp/zig minimal tier; 26 grammars full tier; re-highlight on edit,
-visible-range first), Save, open-at-line, undo/redo. Image: zstbi decode,
-kitty graphics when available, petscii matcher fallback (C64/terminal
-palettes, ascii glyph set toggle). PDF (`-Dmupdf`, native default on): MuPDF
-rendering as one continuous page strip, real text search, mouse text
-selection, the document outline into `+PdfSections`, fit-width/fit-height and
-a themed duotone tint — the last three named in the pane's own live tag.
-Embedded PDF links use the ordinary right-click Look gesture. Hovering one
-shows an accent highlight and a pointing hand in graphical frontends; dragging
-still selects text. Internal links reveal their page and anchor. When the
-annotation's visible label resolves to a different Look location, a `+Links`
-output lists the visible destination and the embedded destination for you to
-choose with Look. Internal destinations in that list use `document.pdf:page`.
-Labels that are not locations follow the embedded link directly; identical
-destinations open once. Links use the same supported URL and file-location
-rules as Look.
-Tutor: embedded text as file pane.
-
-*Language.* The native host snapshots each request and runs it outside the UI
-loop. Every language's server, Zig's zls included, is a child process the
-JSON-RPC client speaks to. Results become output rows or edits, applied only while
-the request's pane and revision still match. Web and board builds have no
-language backend. See `docs/lsp.md` for configuration and commands.
-
-*Chrome.* One theme per `.zig` file, folded into the ring at comptime: ours
-first — helix (default; near-black page, chrome one grey-ramp step off it,
-colors lifted from the helix editor's own theme), dark, and the acme-light one
-named simply `acme` — then
-everything `tools/gen_themes.zig` exports at build time out of the vendored
-helix `.toml` and zed `.json` sources in `vendor/themes`, which is all 214 helix
-ships plus zed's 11, sorted by name. Names are unique by construction: helix's
-own acme is vendored as `acme_helix.toml` so it cannot collide with ours, and
-every zed theme takes a `_zed` suffix for the same reason. helix and dark leave
-a child's ANSI palette native; acme and the zed exports resolve it onto
-the page so shell output stays readable on a light one. A theme owns the page,
-the tag bar, the gutter, the move box and 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. `NextColor` browses the ring one
-step at a time — at 228 it is no longer how you REACH one —
-`Theme <name>` jumps to one and `Themes` (`SPC t t`) lists them all into an
-output buffer whose rows are those very commands — execute a row (Tab, middle
-click) and the theme goes on; n/N select such a row WHOLE, since a command
-line holds no place to pick out of it — the third grain of that motion, the
-other two being one stop per ROW in a results list (the location at its head,
-never the matched text after it) and every look-able word in free text. Native
-shells also accept `ThemeFile <path>`: one complete ZON `Theme`, loaded at
-runtime and, where document watches are available, watched with the same
-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. Eleven panel
-transitions and three scene bits are settings, one builtin each, generated from
-`config.Runtime.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. The default 9P socket serves the control tree; 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
-tab (new — the prototype has no web link handling). One-finger touch is
-deliberately complete by itself: tap = LOOK; drag past a small slop = natural
-scroll, with no LOOK on release. A build-generated read-only archive lets LOOK
-open the current contents of tracked or new/nonignored Pardes `.zig` files despite the web
-shell having no host filesystem. The published launcher dump is captured from
-a running Pardes TTY after
-`git ls-files --cached --others --exclude-standard -- '*.zig' | sort`, so its
-complete terminal listing is the same tracked-plus-new/nonignored set the user
-can open. The freestanding module carries no host
-libc, SDL, or WebGL; Tree-sitter's C runtime and the selected parsers link into
-the module through a tiny local ABI shim (Zig is the compact web default). A body gesture retains
-tap-LOOK/drag-scroll, but finger-down on
-a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture
-for its lifetime, keeping layout drags out of the scroll heuristic. Touch
-input translation and scroll/tap state live entirely in the JavaScript shell.
-The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V),
-with words shaped by HarfBuzz so a font's ligatures span their cells
-(`Ligatures off` draws every cell as its own glyph).
-The browser exposes each cell as selectable, inspectable text and applies the
-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.