#set page(margin: 2.2cm) #set text(font: "New Computer Modern", size: 10.5pt) #set par(justify: true) #show heading: set block(above: 1.4em, below: 0.8em) #align(center)[ #text(17pt)[*Pardes: A Text Environment*] #text(11pt)[design for the rewrite — 2026-07-05] ] = What it is Pardes is a text environment in the acme tradition: columns of panes, each pane a tag line plus a body; the body is a live terminal, a file, an image, or a PDF. The mouse carries meaning — left selects, middle executes, right looks. Everything on screen is text and all text is equally alive, whether the shell printed it or the user typed it. It runs in four places: the terminal (libvaxis), a native SDL3 window (the steamdeck), the browser (a freestanding wasm core driven by vanilla JavaScript and rendered as HTML/CSS), and a native macOS app (an AppKit and CoreText shell over a static `libpardes.a`). The rewrite exists because the prototype grew three parallel implementations of one program. The rewrite has exactly one program and four thin shells. = The shape Pardes is a *library*, in the way ghostty-vt is a library: you feed it bytes and events, and you read state out of it. Every platform owns its own event loop and its own renderer; the core owns everything the user would recognize as pardes. #table( columns: (auto, 1fr), stroke: 0.4pt, [*core*], [layout, panes, modes, selection, click semantics, themes, text of the UI. Pure state machine: `update(event)` mutates, `render(arena)` returns the surface. No rendering, no event loop, and no IO that has an effect for it — but "no syscalls" was never true and is less true now: `look` reads a file a Look opened, walks a directory for Find and reads every candidate for Grep, `fonts` walks the font directories, and MuPDF opens a `.pdf`. Those are the ones that are cheaper done in place than round-tripped through an effect and back; everything with a lifetime — a pty, a window, the clipboard — is still asked for.], [*shell*], [one MODULE per platform (tty is one file; gui adds the CRT and gamepad files, a C font loader and twelve GLSL shaders; web and macOS each add a host language). Owns the event loop, translates native input into core events, renders the core's surface, and performs the core's requested effects (spawn a shell, write a pty, open a link).], ) This also collapses the prototype's *two* applications into one: the browser build today is a separate 800-line read-only replay viewer. In the rewrite, loading a dump of another instance is a first-class feature of the one application, the way acme handles dumps: `pardes -l state.zon` on every platform reconstructs the panes (terminals by replaying their raw VT streams into fresh emulators, files, images and PDFs from their bytes — a PDF rides the dump's `image` kind, carrying its path, its bytes and the page it was on). The web shell embeds a dump and leaves `spawn` unanswered. Same core, no viewer fork. The boundary is two data types, both plain values: *Event* (in): `key` (which carries typed `text`), `mouse` (press/release/motion/ drag; button left, middle, right and the four wheel directions; cell position; and the `ctrl` flag, because Ctrl-left-click is goto-definition), `pinch`, `touch_scroll`, `resize` (cols, rows, and — in a MuPDF build — the cell's pixel size), `paste`, `pdf_scroll`, `output` (bytes a pty produced, tagged with the pane id), `eof`, `command` (a builtin line arriving from another process over the nested socket), `tick`, and the answers to an ask: `lsp_resp`, `pipe_resp`, `file_changed`. Touch policy belongs to the shell: web maps a one-finger tap to right-button LOOK and a drag past its tap slop to natural scrolling; native SDL keeps its two-finger gestures. A joystick cursor is a motion plus buttons. The shells do this translation and nothing else with input: one event vocabulary, four dialects translated at the door. *Surface* (out): `cols`, `rows`, a grid of cells — grapheme, fg, bg, attrs — a cursor, and the pixel attachments. This is the *canonical interface*: it is literally what the tty shell hands to vaxis, cell by cell. SDL rasterizes the same grid through a glyph atlas; the browser reads a packed copy and patches native DOM cells. If it cannot be expressed in the surface, it does not exist in pardes. (Pixel images ride along as a list of PLACEMENTS rather than one per pane — a PDF pane contributes every page its viewport intersects, and pages can be arbitrarily short. The SDL shells blit RGBA, the tty shell falls back to the petscii matcher, kitty graphics when available. The per-pane rectangles live on `Pardes`, not on the surface: a shell that wants them asks the core.) *Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`, `resize_pty{pane, cols, rows}`, `open_link`, `new_file{pane, serial}`, `save_file{pane}`, `write_dump`, `set_clipboard`, `read_clipboard`, `lsp`, `pipe`, `watch`, `quit`. The core never performs IO for any of these; it asks — and four of the asks have an answer coming back: `read_clipboard` returns an ordinary `paste` event (or nothing at all when the shell cannot read the clipboard — most terminals refuse the OSC 52 read), `lsp` returns `lsp_resp`, `pipe` returns `pipe_resp`, and `watch` returns `file_changed` whenever the shell notices the file moved under it. This is what makes the browser build honest instead of a fork: a wasm shell simply answers `spawn` differently (or not at all) — the core does not know. Platform divergence inside the core is a comptime tag, used the way the stdlib uses `os.tag`: ```zig pub const Platform = enum { tty, gui, web, macos }; // in look.zig: if (platform == .web and target.kind == .url) return .{ .open_link = target.text }; ``` There is one such file by design: `look.zig` holds *what a click on text means* — expansion of the word under the click (acme's `isfilec`), path/`:line` resolution, url detection, and the per-platform outcomes, kept adjacent so the divergence is visible in one screenful. = Data structures The whole state is one struct, fixed-size where it can be. Panes themselves are heap-allocated on demand and their contents (file bytes, the yank register, PDF rasters, tree-sitter state) grow with what you open; everything else is sized at init. ```zig Pardes ncol + col_weight[6], col_terms[6][16], col_n[6] // columns as flat arrays panes: [16]?*Pane // slot array; id = index active: usize drag: Drag // none | select | move | border_v | border_h | tag theme_idx: usize // index into themes (228 of them) colors_on: bool rects: [16]Rect // where each pane landed, this frame effects: [4096]Effect + head/len // a fixed ring Pane tag: TagLine // live prefix (cwd/path) + editable tail kind: enum { terminal, file, image, pdf } // + independent optional payloads vt, stream: ghostty-vt Terminal and its stream — on EVERY pane, not just terminals, which is what lets the same keys and the same parity suite drive a file and a shell file: ?File / image: ?Image / pdf: ?PdfView vweight: f32 mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty // `v` adds a fourth thing to DISPLAY, "select", // which is a bool on top of normal, not a mode cursor: absolute body position // rides the scrollback, not the screen sel: [3]Sel + msel/vsel + sels[63] // per-button block sels, the line and // char modal ones, and up to 64 cursors ovl: ?Ovl // ONE typed run, anchored to an absolute row undo: two stacks, not one — ed_undo[64] of overlay snapshots for a terminal, // and File.undo of content snapshots for a file File = path + bytes + line index + Syn (tree-sitter highlight bytes) Image = decoded RGBA + petscii grid cache PdfView = MuPDF document + per-page rasters + cached outline ``` Layout is arithmetic, not objects: columns are weights over the width, panes are weights over the column. `splitBelow` shrinks only the source pane; `absorbVWeight` gives a dying pane's weight to one sibling; an emptied column hands its width to a neighbor. Minimal motion is the invariant: an operation on one pane may not move panes it does not touch. = 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 metric was lines of code, and for the rewrite itself it held: 7,626 lines replaced the prototype's 12,072 with three backends instead of one and a half. It has not held since, and the table below is the honest version rather than the flattering one. Everything after the rewrite — PDF, a language backend, a fourth shell, 228 themes, multiple cursors — was added, not traded for something removed: #table( columns: (auto, auto, auto, 1fr), stroke: 0.4pt, [*module*], [*at the rewrite*], [*now*], [], [core and the pieces that grew out of it — 25 files: pardes, look, syntax, image, dump, builtins, config, output/file/term panes, normal\_input, nested, fonts, themes, …], [4,289], [22,220], [all semantics, all platforms], [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [2,569], [carried over], [pdf.zig], [—], [1,581], [MuPDF, `-Dmupdf`], [lsp/ (seam + in-process ZLS backend)], [—], [1,663], [`.zig` only], [shells: tty 1,396; gui 4,968; web 601; macos.zig 1,579], [2,768], [8,544], [translate + render only], [main.zig + build.zig], [484], [1,471], [four targets, four codegen passes], [*application total* — every `.zig` under `src/`, plus `build.zig`], [*7,626*], [*38,048*], [vs the prototype's 12,072], ) Those six rows are a partition: no file is in two of them and none is left out, which the old table could not say (its rows summed to 1,325 more than its own total, and twenty-six files were in no row at all). What the total deliberately does NOT count, because none of it is Zig under `src/`: 2,250 lines of Swift for the macOS app and its icon generator, 1,541 lines of C bridging MuPDF, 549 of JavaScript, HTML and CSS for the web shell, 216 of C for the SDL font loader, `mupdf.zig` (355) at the root, and `tools/` + `build/` (896). The test tree is another 5,234 lines of Zig across eight harnesses, plus 1,028 of Swift and 610 of JavaScript — against 831 at the rewrite. Two things that number is not. It is not one program's worth of growth — the macOS and web shells and the PDF and language work are four products sharing a core. And it is not licence: the rule that survives is the local one, that a change should leave the file it touches no longer than it found it. = Testing: the old program is the oracle Before the rewrite compiled, the prototype got a harness (`snapshot.zig`, the `zig build snap` step): it forks either binary in a pty, feeds it an *event script* (one line per input: keys, SGR mouse, resizes, sync points), and captures the rendered grid — text, cursor, and per-cell style runs — through its own ghostty terminal. Goldens are generated from the old binary (`zig build snap -- --update`); the new binary must reproduce them byte for byte (`pardes-snap pardes/zig-out/bin/pardes`). Eighteen scripts covered the checklist in Appendix A at the rewrite; eighty-seven cover it and everything since. All pass. Determinism pins: fixed workdir paths (they appear in tags), a controlled `$HOME` with `PS1='$ '`, `LC_ALL=C`, and a grid-stability sync primitive instead of timing guesses. Three deliberate deviations surfaced by the oracle, kept after review: the greeting `ls` waits for the shell's first output (the prototype raced bash's startup and won only by allocator luck); dump files compare with base64 pty history elided (it encodes prompt-redraw micro-timing, not state — the cleaned text fields are the contract); and typed insert runs don't survive a dump replay (they are an overlay, not pty bytes — the prototype's replay viewer had the same semantics). Shell correctness is no longer out of scope, and that is the biggest change to this section since the rewrite. `web-snap` / `web-e2e` drive headless Chromium over CDP with real DOM pointer and touch events and diff `test/web-snapshots/`; `image-harness` and `pdf-harness` snapshot native PIXEL output through kitty graphics and SDL; `macos-e2e` is an offscreen AppKit snapshot suite over its own seven scripts. What remains untested by a snapshot is the last hop — that vaxis diffs correctly onto a real terminal — plus each shell's inline unit tests (the FreeType atlas raster, the trackpad and rotation maths, the gamepad replay). = Build Every `zig build` builds ONE platform: `platform` is an option defaulting to `.tty`, so a bare `zig build` gives `pardes` (vaxis) and the other three are separate invocations — `-Dplatform=gui` for `pardes-gui` (SDL3), `-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=` for `pardes.wasm` plus a vanilla DOM shell (any other spelling of that one fails on purpose rather than building something silently wrong), and `-Dplatform=macos` plus the `macos-app` step for `pardes.app` wrapped around a static `libpardes.a` on a Darwin host. Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (a pinned fork, lazy), FreeType, MuPDF (`-Dmupdf`, on by default everywhere but the web, lazy), ZLS, mvzr (the regex engine behind `s`/`S`), and zig-tree-sitter + 26 grammars — all pinned through `zig fetch` and wired in `build.zig`. Generated during the build: `highlights.scm` → an options module; the vendored helix/zed theme sources → the generated half of the theme ring; the tracked `.zig` sources → the web shell's read-only archive; eight GLSL shaders → SPIR-V. That last one is the only build input wanting a tool a stock machine lacks (`glslc`), so it is also the only one whose output is committed: `zig build shaders` writes `shaders/prebuilt/*.spv` and `-Dprebuilt-shaders` embeds that copy rather than shelling out, which is 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. = What is deliberately absent No render abstraction over the shells (the surface *is* the abstraction). No plugin system. No async runtime in the core — the shells may thread, the core is single-threaded by construction. No 9P yet — but the library boundary is exactly where acme put the file server, and a fifth shell could serve `Surface` and `Event` over 9P without touching the core. Half of that is already built and unnamed: `src/nested.zig` listens on a unix socket at `$XDG_RUNTIME_DIR/pardes-.sock` (`~/.local/state/pardes` without one), accepting exactly one verb — `Look `, and nothing else, which is the security property — and that is how a `pardes ` run inside a pardes hands the file to the outer session instead of stacking a second full-screen UI inside one of its panes. "No config files" held until the startup file (`docs/config.md`) arrived, and that is the narrowest thing the phrase could still cover: a list of builtin COMMANDS run before the first frame — no schema, no new vocabulary, and no key remapping. The keymap is `src/config.zig`, compiled in, where a wrong binding is a compile error rather than a silent no-op. = Appendix A: feature parity checklist From the prototype survey; every line is covered by at least one of the event scripts in `test/snapshots/` — the original eighteen (boot, tty, edit, scroll, modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, ttyonly, syntax, fileedit, images), and sixty-nine more added since for 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); 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; 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. 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.* Live prefix (mode indicator, cwd or path) + editable tail with the full modal editor; defaults `New Del` / `Save New Del`; an image tag is `img New Del` like any other (its renderer toggles are builtins under `SPC t p/l/a`). Topbar: `New Newcol Find Grep Help Tutor Dump NextColor Debug Kill` — execute-only (left click inert); 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), 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. Tutor: embedded text as file pane. *Language.* ZLS compiled in and called IN-PROCESS — no subprocess, no JSON-RPC, no daemon and nothing cached between queries — behind a one-function seam (`lsp.query`) so that swapping a backend touches nothing else. `.zig` only, and on demand only: helix's five `g` gotos, ten builtins under `SPC l`, `]d`/`[d`, `=`, and insert-mode Tab after a `.`. Answers become rows in `+Search`, `+Hover` or `+Lsp` — output buffers of the same kind `/`, Find and Help fill — or, for a rename, byte ranges the core applies in one undo. The web shell has no threads and therefore no backend at all. *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. Colors toggles all recolor passes; Debug stats overlay; Dump writes state ZON. *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 Git-tracked 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 '*.zig'`, so its complete terminal listing is the 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). 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.