1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
|
#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 (wasm + SDL3 + emscripten). 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. The SDL shells rasterize the same grid
through a glyph atlas. 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}`,
`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: u2 // index into themes
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)], [2,768], [translate + rasterize 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` + shell page (emscripten). Same dependency
set as the prototype — ghostty, vaxis, uucode (shared config), zstbi, SDL
(castholm, lazy), zig-tree-sitter + grammars — all via `zig fetch`, wired in
`build.zig`. Codegen during build, consumed by comptime: tree-sitter
`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; `--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 (focus_hist
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 `Del` / `Save Del`; an image tag is `img <path> Del`
like any other (its renderer toggles are builtins under `SPC t p/l/a`). Topbar:
`Kill Newcol Tutor Debug NextColor Dump` — 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.* Themes helix (default; near-black page, chrome one grey-ramp step
off it, colors lifted from the helix editor's own theme), dark, acme-light —
the first two leave a child's ANSI palette native, acme-light resolves it onto
the page so shell output stays readable on yellow. 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. 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, pinch, render scale, 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 published web build carries only the
Zig tree-sitter grammar and colors opened source panes without shipping unused
C/C++ parsers. 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
circles, trails, and click flashes render only while Debug is enabled. SDL
shells use an
stb_truetype atlas over the SDL GPU API (SPIR-V on native, GLES3 on web),
letterboxed web scaling, pinch, and a touch debug overlay. `PARDES_TEST`
provides PPM and text-grid capture; the browser `.snap` harness drives real
Chromium touch input and reads both text and per-cell styles from the rendered
surface. Joystick cursor for the steamdeck is *new* scope — the prototype ships
no gamepad input.
|