// 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 #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 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`, `tag_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 ```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. Scene `Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI. DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes neither effect family. `EffectCode ` 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 #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 ```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 == 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/` 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/` 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 ` to the parent's `pane//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-.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=`, 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 `/dev` rather than `/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=` 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 `git@git.sr.ht:~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 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 $ ^`, `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 ]`), `/` 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: palette: ascii: ` 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. ColumnTags hides that row when space is tight; 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. Zig uses the in-process ZLS backend; configured external language servers use the JSON-RPC client. 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 ` jumps to one and `ThemeSel` (`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 `: 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 `/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 ` 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.