#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, or an image. 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 three places: the terminal (libvaxis), a native SDL3 window (the steamdeck), and the browser (a freestanding wasm core driven by vanilla JavaScript and rendered as HTML/CSS). The rewrite exists because the prototype grew three parallel implementations of one program. The rewrite has exactly one program and three 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, `surface()` describes. No syscalls, no rendering, no event loop.], [*shell*], [one file per platform. 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 and images from their bytes). 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`, `text`, `mouse` (press/release/motion; button left, middle, right; cell position), `wheel`, `pinch`, `resize`, and `output` (bytes a pty produced, tagged with the pane id). 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, three dialects translated at the door. *Surface* (out): a grid of cells — grapheme, fg, bg, attrs — plus a cursor and per-pane rectangles. 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 an attachment per pane: the SDL shells blit RGBA, the tty shell falls back to the petscii matcher, kitty graphics when available.) *Effect* (out, queued): `spawn{pane, cwd}`, `write{pane, bytes}`, `resize_pty{pane, cols, rows}`, `open_link{url}`, `read_file{path}`, `new_file{pane, serial}`, `set_clipboard`, `bell`, `quit`. The core never performs IO; it asks. 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 }; // 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, sized at init, no hidden allocation after: ```zig Pardes cols: []Col // col: weight + pane ids, top to bottom panes: []Pane // slot array; id = index active: Pane.Id drag: Drag // none | select | move | border | scrollbar theme: usize // index into themes (228 of them) colors_on: bool surface: Surface // rebuilt by surface(), arena-backed effects: Fifo(Effect) Pane tag: TagLine // live prefix (mode, cwd/path) + editable tail kind: union { term: Term, file: File, image: Image } vweight: f32 mode: enum { normal, insert, tty } // helix-modal; tty = raw to the pty cursor: absolute body position // rides the scrollback, not the screen sel: [3]Sel + line/char modal sels // per-button block sels, msel, vsel edits: Splice // typed runs anchored to absolute rows undo: edit snapshots // unified undo across term edits and file content Term = ghostty-vt Terminal + its stream (bytes in via Event.output) File = path + bytes + line index + Syn (tree-sitter highlight bytes) Image = decoded RGBA + petscii grid cache ``` 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 is lines of code; the number only goes down. As built, against the prototype's 12,072 lines of Zig: #table( columns: (auto, auto, 1fr), stroke: 0.4pt, [*module*], [*lines*], [], [core (pardes.zig + look.zig + syntax + image + dump)], [4,289], [all semantics, all platforms], [modal.zig + petscii.zig (pure, unit-testable)], [1,410], [carried over, unchanged], [shells (tty.zig; gui.zig = SDL native; web.zig + web/ = browser)], [2,768], [translate + render only], [main.zig + build.zig], [484], [three targets, codegen for queries], [*application total*], [*7,626*], [vs 12,072 — three full backends instead of one and a half], [snapshot harness (snapshot.zig + e2e_harness.zig)], [831], [the parity oracle, kept], ) = 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 cover the checklist in Appendix A; 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 (that SDL draws the grid it was given, that vaxis diffs correctly) is out of snapshot scope and covered by each shell's single smoke test. = Build `zig build` produces three statically linked artifacts: `pardes` (vaxis), `pardes-gui` (SDL3), `pardes.wasm` + a vanilla DOM shell (wasm32-freestanding). Native dependencies are ghostty, vaxis, uucode (shared config), zstbi, SDL (castholm, lazy), FreeType, and zig-tree-sitter + grammars — all pinned through `zig fetch` and wired in `build.zig`. Codegen during build, consumed by comptime: `highlights.scm` → options module; tutor text → embedded pane content. 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 config files. 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 fourth shell could serve `Surface` and `Event` over 9P without touching the core. = Appendix A: feature parity checklist From the prototype survey; every line is covered by at least one of the eighteen event scripts in `test/snapshots/` (boot, tty, edit, scroll, modal, look-file, look-dir, exec, tag, theme, tutor, windowops, dump, load, ttyonly, syntax, fileedit, images). *Layout.* Columns by weight (≤6), panes by vweight (≤16); 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. 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 $ ^ gg ge gh gl G`, `Ctrl-d/u/f/b`, `zt zz zb`), insert entries (`i a I A o O`), `v`/`x` selections, `d c y p u U`, Enter=look Tab=execute at cursor. 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). `p` pastes the yank register; yank mirrors out via OSC 52. *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; 25 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). Tutor: embedded text as file pane. *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, acme-light — 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. helix and dark leave a child's ANSI palette native; acme-light 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 and the move box; the selection colors stay fixed because they encode which button you pressed. `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 — so n/N EXECUTE each row instead of looking it and stepping the list wears the themes. 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.