summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md530
-rw-r--r--README.md253
-rw-r--r--build.zig32
-rw-r--r--build.zig.zon2
-rw-r--r--docs/cloud9.md139
-rw-r--r--docs/config.md790
-rw-r--r--docs/detached.md76
-rw-r--r--docs/divergences.md101
-rw-r--r--docs/fs.md1634
-rw-r--r--docs/open-questions.md83
-rw-r--r--docs/tags.md407
-rw-r--r--docs/v9fs.md130
-rw-r--r--src/9p.zig2
-rw-r--r--src/9p_io.zig96
-rw-r--r--src/CHANGELOG.md59
-rw-r--r--src/File.zig36
-rw-r--r--src/Messages.zig16
-rw-r--r--src/builtins.zig23
-rw-r--r--src/dump.zig46
-rw-r--r--src/exec.zig50
-rw-r--r--src/fs.zig25
-rw-r--r--src/look.zig89
-rw-r--r--src/main.zig123
-rw-r--r--src/ninep/addr.zig10
-rw-r--r--src/ninep/cols.zig5
-rw-r--r--src/ninep/ctl.zig344
-rw-r--r--src/ninep/events.zig37
-rw-r--r--src/ninep/pane.zig141
-rw-r--r--src/ninep/pty.zig39
-rw-r--r--src/ninep/testing.zig7
-rw-r--r--src/ninep/tree.zig208
-rw-r--r--src/pardes.zig27
-rw-r--r--src/regexp.zig250
-rw-r--r--src/sam_edit.zig37
-rw-r--r--test/fs.py91
-rw-r--r--test/gui_monkey.py179
-rw-r--r--test/monkey9p.py1993
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig20
41 files changed, 5020 insertions, 3116 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 16e90dc9..7cdca1a0 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -1,472 +1,174 @@
---
name: pardes-9p
-description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging.
+description: Inspect and drive a running Pardes editor through its 9P control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging.
---
# Pardes over 9P
-Pardes is driven by reading and writing files. Prefer ordinary file tools over
-a mount; reach for the Python client only when nothing is mounted, or when the
-work needs a fid held open. Do not build another wire client: project tooling
-stays in Zig. Run the examples from the repository root; paths below are
-relative to that root unless linked.
+Pardes is files: `cat`, `echo >`, `ls` and shell scripts are the whole
+interface. [docs/fs.md](../../../docs/fs.md) is the reference for every
+file; this page is recipes. Paths are relative to the repository root.
-## Find the session through the mount
-
-Check `$NINE_MOUNT` first. `9ns --mntgen` sets it for every process it starts
-(an interactive shell on this machine runs inside one), so a set `$NINE_MOUNT`
-means the posted-9P registry is mounted there, usually `/mnt/9p`, and each
-running editor is a directory `$NINE_MOUNT/pardes/<pid>/` (a `--detach=NAME`
-session's is `pardes/NAME/`; `pardes --detach=NAME &` runs the editor as
-that process, so `$!` is its pid, and `$m/status`'s first line, `pid <n>`,
-says the same). Inside a pane, `$PARDES_9P` is that session's
-socket (`/run/user/1000/pardes-9p-<pid or NAME>.sock`), which names the
-directory either way, and `$PARDES_PANE` is the calling pane's serial:
+## Find the session
```sh
[ -n "$NINE_MOUNT" ] || echo 'no 9P mount: use the Python client below'
s=${PARDES_9P##*/pardes-9p-}; m=$NINE_MOUNT/pardes/${s%.sock}
-# under `9ns --unix SOCKET -- cmd` the session is the mount itself: m=$NINE_MOUNT
cat "$m/index"
-cat "$m/pane/$PARDES_PANE/body"
```
-From there the editor is files: `cat`, `echo >`, `ls` and shell scripts are
-the whole interface, and nothing below needs more than they do.
+`$NINE_MOUNT` is set by `9ns --mntgen` (usually `/mnt/9p`). `$PARDES_9P` is
+the session socket of the pane you run in, `$PARDES_PANE` that pane's
+serial. Under `9ns --unix SOCK -- cmd` the session is the mount itself:
+`m=$NINE_MOUNT`. In a `Tty9p` shell: `m=$PARDES_MOUNT`. A dead session's
+entry answers `Input/output error`: name the session, never glob. A running
+session serves the binary that started it.
-Entries for dead sessions stay listed and answer `Input/output error` on any
-access, so name the session you mean rather than globbing or taking the newest.
-`cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports
-the `pid`, `version` and `panes` of a session new enough to serve it.
+## Look around
-`$NINE_MOUNT/pardes` is the registry of posted sessions, mounted once for the
-machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which
-gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md)
-for that. Either mountpoint serves the same tree. A `9ns --unix SOCKET -- cmd`
-mount exists only inside `cmd`'s private namespace, and there `$NINE_MOUNT` is
-the session's own root (`$NINE_MOUNT/index`), not a registry; to share one,
-use the registry (`9ns --mntgen`).
+```sh
+cat "$m/index" # serial kind dirty name column, a pane a line
+cat "$m/layout" # columns, then `active <serial>`
+cat "$m/focus" # the pane with the keyboard
+cat "$m/README" # the one-screen guide
+cat "$m/commands" # every builtin and where it goes
+```
-A running session serves whatever binary started it. If a listing does not
-match this document, that session predates the change; restart it.
+Parse an index row with `rsplit(maxsplit=1)` first: names may hold blanks.
+Re-read the index after anything that opens or closes panes.
-## The tree, and what to do with it
+## Open a file, go to a place
+```sh
+echo "$PWD/main.zig:120" > "$m/look"; cat "$m/look" # serial it landed in
+echo "main.zig:0/fn main/" > "$m/look" # first match; without 0, the next after dot
```
-$m/README the served guide, worth reading first
-$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial
-$m/status pid, version, panes
-$m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/
- select by address, as acme's look, from the file's dot: file:0/re/ for the
- first match; file:N selects the line; a miss changes nothing, logs err, and
- look reads back empty)
-$m/exec write a line = a middle click: an editor command word, or a shell line
-$m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open:
- exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3
- (a fresh terminal: waits for its first prompt; if none comes, interrupt the read)
-$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>,
- run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>,
- ask <serial> <what> <choices>, answer <serial> <choice|->, changed <serial>
- [reloaded|deleted] (under unsaved edits; a clean reload; gone from disk),
- unsaved <serial> <name> (each pane a refusal is about), newcol|delcol <serial>, restored|restoredcol <old> <new>,
- dump|restore <path>, err <serial|-> <file>: <why>
- (exec 3<>$m/log; echo follow >&3; cat <&3 replays what is there, then waits for new
- ones; `echo follow new` waits for new ones only; tail -f does not wait;
- while read -r line <&3; do ...; done loses nothing within a session (through
- FUSE, `read -t` never times out: use `timeout N` around the loop): a Restore
- hangs every connection up, so dial again, and restart a 9ns mount)
-$m/screen the rendered screen as JSON, frozen per open
-$m/listeners this session's dial addresses
-$m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it);
- echo a serial into it to move the keyboard (a folded pane stays folded)
-$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
- `size <cols> <rows>` sizes a --detach session no frontend is attached to (160x50 before)
- (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the
- commands pardes started (command panes, lines it typed into shells), a word
- matching a command line's first word; Exit and Restore refuse once,
- logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with
- `<name>: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit
- again to discard)` for more (Restore's says `Restore again`); on screen the list stays
- in the one +Unsaved pane under a short notice (`3 unsaved panes — Exit again to discard`); a second refusal names only the panes edited since the last,
- as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`;
- Kill stops a command pane's whole line, `&` jobs included; of a line typed into a
- shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done`
- bash and fish both run the echo); Joincol folds the keyboard's column into the one
- on its right, its panes going below that column's own, in order, and needs such a column);
- a pane's builtins (Del, Save f, Collapse, which folds that pane, and the
- column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl
-$m/commands every builtin: `Word`, `Word arg`, then `root`, `pane` or `both` (which ctl takes
- it), a setting's values comma-joined, then ` -- ` and one sentence of what it does
-$m/recent files opened lately, closed ones too, most recent first: `open|closed <path>`;
- lost a file you closed? read this, or run Recent and look at its row to reopen it
-$m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial>
-$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do)
-$m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec
- file reads back the serials its last write touched
-$m/col/<serial>/ctl Delcol, Joincol, New, Tty on that column; col/<serial>/exec a word as a click in its tag;
- rmdir col/<serial> closes an empty column
-$m/pane/new open it to make a pane (a scratch named <dir>/+New), read names it; it goes in
- the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if
- empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules);
- a session holds 64 panes (16 on the board): past that, every route that opens one
- fails with `no space for a pane: 64 max` (ENOSPC), and so does one whose column
- has no room (each pane keeps its tag and 2 rows: `no space for a pane in that column`); rmdir $m/pane/<n> closes it; a column's last pane leaves the column EMPTY
- (focus reads empty, the log says only del), and the session's LAST pane QUITS it
-$m/pane/<n>/errors write-only: text appended to the +Errors pane of the pane's directory
-$m/os/ the host filesystem
-```
-The first field of an index row is a stable pane serial, not a slot or row
-number. A row is `serial kind dirty name column`, the last word the pane's
-column serial, so `head, col = row.rsplit(maxsplit=1)` then
-`serial, kind, dirty, name = head.split(maxsplit=3)` keeps a name with spaces
-intact. Re-read the index after anything that might open,
-reuse or close a pane.
+A miss opens nothing, reads back empty and logs an `err <serial> look: ...`
+line; the write itself succeeds. `L:C` columns are bytes.
+
+## Make, fill, name and save a pane
```sh
-cat "$m/index" # which panes exist
-n=$(cat "$m/pane/new") # make one, take its serial
-printf 'text\n' > "$m/pane/$n/body" # replace its text (> truncates)
-printf 'more\n' >> "$m/pane/$n/body" # append
-cat "$m/pane/$n/tag" # what its tagline offers
-echo notes.txt > "$m/pane/$n/name" # rename the buffer
-echo Save > "$m/pane/$n/exec" # save it (or to its ctl)
-echo Tty > "$m/pane/$n/ctl" # a terminal in its directory
-echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it landed
-rmdir "$m/pane/$n" # close it, dirty or not
+n=$(cat "$m/pane/new") # each open makes one: read it once
+printf 'hello\n' > "$m/pane/$n/body" # > replaces the text
+printf 'more\n' >> "$m/pane/$n/body" # >> appends
+echo "$PWD/notes.txt" > "$m/pane/$n/name"
+echo Save > "$m/pane/$n/ctl"
+rmdir "$m/pane/$n" # close it, saved or not
```
-A session may open its own mount from inside itself: a Look at `$m/anything`
-in the editor that serves `$m` is answered on the connection's task while the
-editor's own syscall waits. `/n/self/...` names the same tree without leaving
-the process.
+## Edit text
-**Opening** `$m/pane/new` is what makes a pane, and reading the open file
-answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one,
-two reads of the same open file answer the same serial, and closing it leaves
-the pane. A pane is named by the serial the editor gives it, never by a name
-you choose.
-
-A *stat* makes nothing, which is the whole reason the allocation sits on open:
-`new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and
-anything else that stats every name a listing handed it stay inert. acme
-allocates on the walk instead and lets it land inside the new window, so
-`/dev/new/body` works in one step — it can afford that because a Plan 9
-directory read carries every entry's stat and nothing walks. Under a kernel or
-FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be
-created or removed, and no read creates anything.
+```sh
+p=$m/pane/$n
+echo 'Edit ,x/foo/c/bar/' > $p/ctl # sam edit, one undo step
+echo '/old/' > $p/addr; printf 'new' > $p/data # replace the next match
+echo 0 > $p/addr # back to the top
+echo '3' > $p/addr; : > $p/data # delete line 3
+cp $p/addr $p/dot; cat $p/sel # select it, read the selection
+```
-`look` and `exec` are the editor's two clicks, one per line of a write, at the
-active pane from the root and at that pane from `$m/pane/<n>/look` and
-`$m/pane/<n>/exec`. Reading any of them answers the serials the last command
-made, or the pane it focused or acted on: an open's own last write's, or, on
-an open that never wrote, the session's last. With other clients about,
-write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`).
-A read is a stream: once read, a second read on the same fd gives EOF (on
-an open that wrote, until its next write); open again, or seek to 0, to read
-it again. Each command line runs once whole, however a mount cuts a big write; a last
-line with no newline runs when the open closes, and an Edit block still open
-then fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
-line), changing nothing. One rule: a builtin that fails, whether through a
-ctl (a pane's pty/ctl `exec` too), look, exec, tagexec or a column's exec,
-fails the write (EINVAL for a malformed line, ENOENT for what is not there --
-a directory gone, a Find or Grep with no hit -- else EIO or an errno that
-fits) and logs one `err` with the reason, no `msg`. A look that finds nothing is no failure: it answers
-nothing and logs one `err`. A command line run in a command pane is judged
-by its `exit` record. A word no builtin
-knows (a typo included) is a command line: written at a terminal at its
-prompt it is typed into that shell; from anywhere else it runs as a command
-pane, a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set)
-running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run
-<serial> <line>` and `exit <serial> <N|?>` -- `exec` reads back its serial,
-so follow `log` for the exit. The directory's next command reuses a finished
-command pane, below what it showed. A terminal bound as a REPL (`Repl
-python` on its ctl) takes the middle clicks made on a `.py` body instead,
-but never a 9P write: a script sends code by writing the REPL pane's
-`pty/data`, and runs a command from such a file with `Exec <text>` or the
-tag. A bound terminal's `ctl` line ends with its id (`python-a`); an event
-record written back from such a file's body goes to its REPL, as the click
-would. `Repl -` unbinds; a bare `Repl` says the binding. With several
-REPLs bound for a language an exec asks which, logged `ask <serial> repl a b`
-(Del's side from the keyboard is `ask <serial> del k j`): answer with
-`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing (the
-log says `answer <serial> a|-`). Kill does not stop what a REPL runs (it was
-typed, not started by pardes): `echo 'sig INT' > $m/pane/<repl>/pty/ctl`
-interrupts it. A range of a `.py` pane goes to its REPL by writing the event
-record `MX<q0> <q1>` to that pane's `event`. A chorded exec's record (flag 8)
-is followed by two, its argument and where it came from; write all three back
-as read (one write or three) and it runs once with its argument. `Tty`'s argument is a shell
-(`Tty fish`), and `Tty` on a pane's ctl opens a new terminal pane. After a
-Restore, panes have new serials (`restored <old> <new>` in the log), a
-command pane shows how it ended, `exit N` (`exit ?` if it was still running when
-dumped), and does not run again, and REPLs are unbound. Multi-line code
-written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~`,
-then, in a separate write once the REPL has echoed the paste (Python 3.13+
-takes a `\r` read with the paste as part of it, even for one line), `\r`.
-A paste of one line needs that one `\r`; a paste of more than one line
-needs a second `\r` unless the pasted code ends in a newline (one Enter
-leaves a multi-line input at `...`). A middle click, or an event
-write-back, sends what it needs by itself. Sent line by line, a blank line ends a Python block, and Python
-3.14's REPL auto-indents each line it is typed. Every refused 9P write adds an `err <serial|->
-<file>: <why>` record to `$m/log`; through a mount the write itself only says
-`Invalid argument`.
+`addr` searches on from the last address (it is the pane's, shared by all
+clients); after a `data` write it sits just past the text, so write `addr`
+before each replacement. A failed `addr` leaves none and `data` refuses:
+check the write's status. An Edit block of several lines goes in one open:
+`printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl`.
-## Edit through addresses, dot and the flag files
+## Run a command
-For a file or scratch pane, with `pane=$m/pane/<serial>`:
+```sh
+echo 'make test' > "$m/exec"; c=$(cat "$m/exec") # a command pane; its serial
+grep "^exit $c " "$m/log" | tail -1 # `exit <serial> <N>` once done
+```
-Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/'
-> $pane/ctl`. `Edit` takes sam's command language (acme's): addresses, `x y g
-v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from
-exec or a tag, not a one-line ctl write). Its changes are one undo step, and
-one that fails changes nothing and fails the write with acme's words (`Edit:
-no substitution`), logged as `err`; an `x` that finds nothing is no failure
-(sam's), so `,x/zzz/c/bar/` with no `zzz` succeeds silently. A block goes to the pane's `ctl`, the
-root `ctl` (the active pane) or `exec` on one open, in one write or several
-(bash's `printf` writes line by line): an `Edit` line takes the lines after it
-until its `{` closes or its `a`/`c`/`i` text ends with `.`, and runs then. `sel`
-reads the selected text, and a write to it replaces the selection:
+At a terminal's prompt, run and get the status and output in one go:
```sh
-cat > $pane/ctl <<'END'
-Edit ,x/area_of/{
-i/[/
-a/]/
-}
-END
-env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
+t=$(awk '$2=="term"{print $1; exit}' "$m/index")
+exec 3<>"$m/pane/$t/pty/run"; echo 'ls' >&3; cat <&3; exec 3<&- # `exit N`, then output
+printf 'q' > "$m/pane/$t/pty/data" # raw keystrokes (\r Enter, \x03 Ctrl-C)
+echo 'sig INT' > "$m/pane/$t/pty/ctl" # interrupt
```
- `p` and `=` print to the directory's
-`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`.
-
-> **sam gotchas** (as sam does them, pardes too)
-> - `$-1` is the last line when the text ends in a newline (`a\nb\n`: `b\n`),
-> but the one before it when it does not (`a\nb`: `$` is on `b`, so `a\n`).
-> - With a final newline, the line after the last is the empty place at the
-> end, not an error: `N+1` at the last line is `#<len>,#<len>`. Without one
-> (`a\nb`), the last line runs to the end and `N+1` is out of range.
-> - `^` and `$` match at the text's end too: `/^/` from the end finds the
-> empty place after a final newline.
-> - `2,1` is no error: it is `#<start of 2>,#<end of 1>`, an empty range at
-> line 2's start (only a range whose end is before its start is refused).
-> - `y` yields the stretch before the first match too, empty if the text
-> starts with one: `,y/a/` on `abc` gives `` and `bc`.
-> - `c/&/` puts a literal `&`; only `s` expands `&` to the match.
-> - `line:col` columns count bytes from 1: `12:0` is refused
-> (`a column counts from 1`); a tool's character column is the same only
-> on an ASCII line.
-| Operation | Shell | Python client |
-|---|---|---|
-| Read text | `cat $pane/body` | `client.read(pane + '/body')` |
-| Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` |
-| Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` |
-| Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` |
-| Replace that range | `printf 'pub fn' > $pane/data` (`>>` too) | `client.write(pane + '/data', b'pub fn')` |
-| Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) |
+`busy: <program> is running` means the prompt is not free: a program holds
+the terminal, so talk to it through `pty/data`.
-A write leaves `addr` just past what it wrote, so a second `echo x > data`
-inserts after the first: write `addr` again before each replacement. The
-writes of one open of `data` are one undo step (a multi-line `printf` too);
-to make a loop's writes one, `echo 1 > mark`
-(an undo point now), `echo 0 > mark`, the writes, then `echo 1 > mark`.
-A `+New` scratch counts as unsaved (and holds up Exit, Restore, Del) only
-once it holds 100 bytes or more. A read
-of `data` or `xdata` moves `addr` past what it read, as in acme.
-Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
-| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads |
-| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` |
-| Reload from disk | `echo get > $pane/ctl` (refused once while there are unsaved edits; again discards) | `client.write(pane + '/ctl', b'get\n')` |
-| Close it | `rmdir $pane` | `client.remove(pane)` |
+## Follow what happens
-`addr`, `dot` and `limit` each read the pair of offsets they also accept, which
-is why copying one onto another is all that acme's `addr=dot`, `dot=addr` and
-`limit=addr` ever were; a write may also be an address expression (`#0,#5`,
-`/pattern/`, `2+1`, or pardes's `12:5`: line 12, byte column 5, composing as
-`12:5,14:1`; a Recent, +Search or Jumplist row's `12:5-14:2` is taken too,
-through 14:2 inclusive), whose regexps are mvzr's searched as sam searches:
-`^`/`$` match at any line's start and end, `.` and `[^...]` never match a
-newline, the leftmost match wins (the first alternative there, not the
-longest). In a pattern with `\n`, `^` works only first (`^def .*\n` finds
-every def line) and `$` only just before a `\n`; anywhere else the pattern is
-refused, not silently unmatched. `^def|^ ` finds lines starting either way
-(every branch anchored; a mix like `^def|x` is refused). An expression is evaluated from the current address (the last one
-written, or just past the last `data` write): `.` is that address, not the
-selection, `/re/` searches on from its end and wraps unless `limit` is set,
-`?re?` or `-/re/` searches back, `#100,#50` fails `addresses out of order`,
-and a search that backtracks
-past a step budget (about 300 ms) fails with `regular expression search
-gave up, ...`. A failed address says
-why (`no match for regexp`, `address out of range`) and leaves no address:
-`addr` reads empty and `data` refuses until the next good one, so a missed target is never
-written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds
-only the end of a forward search, as in acme, and reads empty until set;
-truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps
-past the end of the line; a line past the end is `address out of range`.
-A refused write repeated the same way is one `err` line counted, `(x4)`.
+```sh
+exec 3<>"$m/log"; echo 'follow new' >&3
+timeout 30 cat <&3 # one record a line, as they come
+exec 3<&-
+```
-`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the
-buffer differs from its file, whether a write pushes an undo point, and whether
-a write scrolls. `tag` reads the path, then the text you may edit: `> tag`
-replaces that text (default words too), `>> tag` appends to it.
+`follow` (without `new`) replays the ring first. `tail -f` sees nothing new.
+Through FUSE `read -t` never times out: use `timeout`. After a `Restore`
+every connection is cut: dial again and restart the mount.
-Address state belongs to the pane, not to a client: it keeps the last range
-written until someone writes another, so writing an address and reading it back
-evaluates it, and two clients addressing the same pane will interfere. Neither
-an open nor a `>` resets it (acme resets it on the first open): each `echo /re/
-> addr` searches on from the last address, so a find-and-replace loop advances.
-Write `0` to start from the top. The search wraps, so stop a find-all loop when
-the address comes back to where it began, or set `limit`.
-`$pane/ctl` reads acme's window status line — serial, tag length, body length,
-a reserved zero, the dirty flag, the width in cells, the font and the tab
-width, then `current` or `notcurrent` — and takes `get` (reload from disk),
-`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
-`Collapse`, `Undo`/`Redo`: 256 steps; with none left they say so, and the
-write succeeds). A `lock` another open holds fails at once with `file in use`
-(EBUSY): retry it. Session builtins and settings go to the root `ctl`, which reads
-back every setting in the syntax it takes. A ctl write is checked whole
-first and refused as `unknown control message "X"` (EINVAL) and the like,
-a required argument missing included (`wrong #args ... "Mount"`); then a line
-whose builtin reports an error fails the write with that error and the line
-(EIO, or an errno that fits: ENOENT for a missing pane, file or dump), after the lines before it took effect. A `Save` on a scratch fails
-rather than prompt. The lock binds only clients that take it, and is held by the
-open that wrote it, so a shell holds an fd across the edit:
-`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
+## Traps
-Terminal panes have no file: writing their `body` sends child input, and
-truncation does not erase terminal history.
+- A refused write says only `Invalid argument` or `Input/output error`; the
+ reason is the `err` line in `$m/log` (`tail -1 "$m/log"`).
+- Only writes log: a refused open, truncation (`> data` after a failed
+ `addr`) or `rmdir` has its errno alone.
+- Settings and session words go to `$m/ctl`; pane words (`Undo`, `Save`,
+ `Del`) to `$m/pane/<n>/ctl`. The wrong one is refused naming the right one.
+- `Exit`, `Restore`, `Del`, `get` refuse once over unsaved text; the same word
+ again discards.
+- A word no builtin knows is a shell command (`exit 127` if a typo).
+- `lock` needs a held fd: `exec 3>$p/ctl; echo lock >&3; ...; exec 3>&-`.
+- plan9port `9p write` truncates: it replaces a whole `body`.
-## The Python client, for what a shell cannot express
+## No mount: the Python client
-Use the existing [Python client](../../../test/ninep.py) when there is no
-mount, or when the work needs a fid held open across several operations —
-`log`, `event` and `pty/data` are consuming queues whose reads park, and shell
-redirection cannot hold one open.
+Use [test/ninep.py](../../../test/ninep.py) when nothing is mounted, or when
+a fid must stay open (`event`, `pty/data`, a followed `log`). Its paths are
+the served root (`/index`, `/pane/2/body`).
```sh
PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY'
import sys
from ninep import Client
-
-with Client(sys.argv[1]) as client:
- print(client.read('/index').decode(), end='')
- print(client.read('/listeners').decode(), end='')
+with Client(sys.argv[1]) as c:
+ print(c.read('/index').decode(), end='')
+ n = int(c.read('/pane/new'))
+ c.write(f'/pane/{n}/body', b'hello\n', truncate=True)
+ fid = c.open(f'/pane/{n}/event', 0) # hold event: clicks come here
+ c.write(f'/pane/{n}/exec', b'Msg hi\n')
+ print(c.read_fid(fid, 0, 4096)) # b'FX0 0 1 6 Msg hi\n'
+ c.close(fid)
+ c.remove(f'/pane/{n}')
PY
```
-`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does
-not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a
-five-second socket timeout, and closes on leaving `with`. Its paths are the
-served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer`
-and `/virtual` are editor Look paths, not server paths — Look
-`/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`.
-
-For live terminal output or plugin events, use `open` / `read_fid` / `close`,
-not the read-until-EOF helper. `pty/data` captures output while held open; it
-is not a history replay. Both files are shared, consuming queues, not
-per-client broadcasts, so a slow reader loses older data. Holding `event` open
-intercepts that pane's Look and Exec clicks -- and lines written to that pane's
-own `look`/`exec`, or to the root's while it has the keyboard, as `F` records
-at `0 0` with the text, and clicks in a terminal's body, also at `0 0` -- so
-it is not a passive logger: a helper holding `event` that writes its own
-pane's exec gets its command back as a record; run it through `ctl` or write
-the record back. To have a record done, write it back: the short form
-`<origin><action><q0> <q1>\n` acts on that range's text, and the whole record
-as read acts on its text when the range is empty (the only way for a record
-at `0 0`). Chord reports need explicit handling. A record is
-`<origin><action><q0> <q1> <flag> <n> <text>\n` and its text may hold
-newlines: read `n` bytes of text, never up to the next newline (acme counts
-runes; pardes counts bytes, as all its offsets are). Every address lands on a
-rune boundary, never inside one and never widened to a grapheme cluster: `#n`
-or `line:col` inside a rune snaps back to its start, a match covers the runes
-it touches, and a combining mark or a CRLF's `\r` is addressable alone.
-A Restore puts a new editor under every client: the Restore write is
-answered, then every connection is hung up (their fids name the old
-editor's panes); dial again, and the new log has a `new` for each restored pane, then
-`restore <path>`, then `restored <old> <new>` for each pane and
-`restoredcol <old> <new>` for each column -- `restore <path>` the authority, since a slow client may see the cut
-before the answer. A 9ns older than cloud9 2a7137c could fail the Restore
-write with ECONNRESET although the Restore went ahead; trust the log. `Dump` writes `pardes-<date>-<time>.zon`, the time in UTC, under
-`DumpDir` (`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) and logs
-`dump <path>`. A relative `Restore <path>` is looked for in `DumpDir` first,
-then in the directory pardes started in; a bare `Restore` takes the last dump
-this session wrote (none yet: refused, name one).
-Read the event implementation before building an interceptor. Close handles in
-`finally`, and disconnect after a socket timeout. The service shares sixteen
-connection slots (the next client's version gets `too many connections`)
-and 32 screen/terminal-history snapshot handles.
-
-A file's qid version is the pane's revision for `body`, `data` and `xdata`, so
-`stat` sees an edit land without reading the text; it stays zero elsewhere.
-Stat sizes are real: for `event` and `pty/data` the length of the record a read
-would answer (zero when nothing is waiting), for `log` the whole ring, the text
-an open would freeze now.
-
-## Terminal input and screen observations
+`Client` takes a Unix socket path or `(ip, port)`; `client.screen()` returns
+the parsed `/screen`. For `event` records and writing them back, see
+[fs.md#event](../../../docs/fs.md#event).
-Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not
-`body`: `client.write(pane + '/pty/data', b'printf hello\r')` submits a shell
-command. For an interactive application, send its actual input bytes; `b'\x03'`
-is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go
-to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`,
-`sig INT` and `exec` (restart the shell; a directory that is gone fails ENOENT), one per line. `pty/status` reads one line: the pty's cols,
-rows and busy (1 while a command runs or text is typed at the prompt).
+## An isolated session
-`client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major
-`cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve
-each cell's style through `styles` when checking highlighting, and compare
-colors and attributes rather than style-table indices or flattened text.
-
-Each screen open freezes one frame, and a terminal `body` freezes its history
-on its first read. `client.read` and `client.screen` reopen each time, so
-repeat the call for a fresh observation rather than polling a stale handle.
-Poll a specific condition with a deadline and a short delay, not a fixed long
-sleep. For large histories, measure whole-body reads separately from screen
-polling; write-to-observation timing includes RPC, rendering and polling.
-
-## Exercise an isolated session
-
-Reuse [test/fs.py](../../../test/fs.py), which starts a private session with
-temporary configuration and cleans up its editor process. Pass an existing
-native binary, not a benchmark executable:
+Never experiment on a session someone is using. Build a binary with
+`zig build install -Dplatform=tty` (a bare `zig build`, or `--prefix
+zig-out`, installs over `~/.local/bin`). `test/fs.py` starts a private
+session and cleans it up:
```sh
-PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY'
+PYTHONPATH=test python3 -B - "$(realpath zig-out/bin/pardes)" <<'PY'
+import sys, tempfile
from pathlib import Path
-import sys
-import tempfile
from fs import session, new_pane, execute
-
-binary = str(Path(sys.argv[1]).resolve())
-with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory:
- with session(binary, Path(directory), 'skill') as (client, address):
- serial = new_pane(client, b'fn main() void {}\n')
- pane = f'/pane/{serial}'
- client.write(pane + '/name', b'probe.zig\n')
- client.write(pane + '/addr', b'#0,#2')
- client.write(pane + '/data', b'pub fn')
- assert client.read(pane + '/body') == b'pub fn main() void {}\n'
- execute(client, serial, 'Msg 9p-ready')
- frame = client.screen()
- assert '9p-ready' in ''.join(cell[0] for cell in frame['cells'])
- print('9P edit, builtin and screen checks passed')
+with tempfile.TemporaryDirectory(prefix='pardes-9p-') as d:
+ with session(sys.argv[1], Path(d), 'probe') as (client, address):
+ n = new_pane(client, b'fn main() void {}\n')
+ execute(client, n, 'Msg ready')
+ assert 'ready' in ''.join(c[0] for c in client.screen()['cells'])
PY
```
-`new_pane` opens `/pane/new` and reads the serial it answers; `execute` writes
-one line to a pane's `exec`. Both are in
-`test/fs.py`. For terminal tests, use
-`session(..., tty=True)` and read
-[test/agent_session.py](../../../test/agent_session.py) for bounded interactive
-driving; its readiness text and history threshold are application-specific, and
-session cleanup alone does not guarantee arbitrary grandchildren have exited.
-Use [test/snapshot.zig](../../../test/snapshot.zig) when editor key/mouse input
-or an independent terminal-rendering comparison matters; 9P screen inspection
-alone does not test physical input routing or the host renderer.
-
-Read [docs/fs.md](../../../docs/fs.md) for runtime mounts, TCP/QUIC listeners,
-Plan9port and Linux v9fs compatibility. Unix is always available; network
-listeners are opt-in and grant full session and OS-file access, so use isolated
-loopback listeners for tests. For event details or anything this page leaves
-open, read the implementation and its tests in
-[src/ninep/](../../../src/ninep/).
+By hand: `pardes --detach=NAME &` with its own `HOME` and `XDG_*`
+directories, then `9ns --mntgen` (the session is `$NINE_MOUNT/pardes/NAME`)
+or `9ns --unix $XDG_RUNTIME_DIR/pardes-9p-NAME.sock -- sh`. Strip every
+`PARDES_*` variable first, or a file argument goes to the session you are
+inside. `session(..., tty=True)` and
+[test/agent_session.py](../../../test/agent_session.py) drive terminals.
diff --git a/README.md b/README.md
index d7548e8c..1fe1167d 100644
--- a/README.md
+++ b/README.md
@@ -10,31 +10,16 @@ One core, five frontends. The core owns editing, layout, rendering, and the
filesystem namespace. Frontends translate native input into `pardes.Event`,
present `pardes.Surface`, and perform host effects such as spawning processes.
-Fifteen [native Pardes themes](docs/themes.md) coordinate the editor, search,
-diagnostics and embedded terminal: `orchard` (the near-black default), `dusk`,
-`ink`, `paper`, `daybreak`, and the Acme-inspired `atelier`. `ink` and
-`daybreak` provide high contrast dark and light choices. Six classic-inspired
-adaptations add `forge`, `lagoon`, `solarium`, `spectrum`, `harvest`, and `clay`.
-`forge_black` and `orchard_black` offer pure-black variations; `forge_soft`
-offers a deliberately softer contrast. Execute `Themes` to choose native
-themes first, followed by the existing legacy/imported collection. `FocusTint` toggles
-the active pane and column tag tints (on by default), and `SyntaxBold` toggles bold
-syntax keywords (off by default), in both GUI and TTY.
+Every session serves its panes, columns and tags as a 9P control filesystem,
+as acme does ([docs/fs.md](docs/fs.md)). `pardes FILE` run in a pane opens
+FILE in that session; `EDITOR='pardes --wait'` makes it your editor
+([forwarding](docs/fs.md#connecting)). `Tty9p` opens a terminal with the
+tree mounted ([docs/v9fs.md](docs/v9fs.md)).
-SDL also supports `Font <name>:<size>` and optional pixel companions in the
-unused workspace tag: `Pet cat`, `Pet frog`, or `Pet off`.
-
-`Mini path` opens a braille minimap with syntax colors: two text columns by four
-lines per cell. It reads through the normal filesystem namespace; run it again
-to refresh. Mini snapshots survive Dump/Restore without rereading the source.
-
-On Linux, `Tty9p` (`SPC n 9`) opens a terminal with the session's 9P tree
-mounted through kernel v9fs. It asks sudo in that pane, then starts your shell
-as your normal user. Access the tree through `$PARDES_MOUNT`.
-See [mounted terminals](docs/v9fs.md). Beyond acme's per-window files the tree
-serves the layout: `/layout` lists the columns, their places and panes, and
-`/tag` and `/col/<serial>/tag` are the workspace and column tags, editable like a
-window's ([the tree](docs/fs.md#the-served-tree)).
+Fifteen [native themes](docs/themes.md) lead a ring of ports and imports;
+`Themes` lists them. `Recent` (`SPC f r`) lists files opened lately, closed
+ones too, and reopens one at its last place. `Mini path` opens a braille
+minimap. Settings and the startup file are in [docs/config.md](docs/config.md).
## Requirements
@@ -43,7 +28,7 @@ fetched and pinned by the manifest; no system package is required for the
terminal build. The SDL shell builds SDL3 and FreeType from source. Native PDF
support builds MuPDF and is on by default (`-Dmupdf=false` to drop it).
Optional 9P-over-QUIC support (`-Dquic=true`) uses system OpenSSL 3.6+ and
-pkg-config. The default Unix socket and optional TCP transport do not need it.
+pkg-config.
## Build
@@ -51,76 +36,42 @@ pkg-config. The default Unix socket and optional TCP transport do not need it.
zig build
```
-That is the whole of it, and it is an *install*: it builds both native shells and
-puts them in `~/.local/bin`.
-
-```
-~/.local/bin/pardes the terminal shell (libvaxis)
-~/.local/bin/pardes-gui the SDL3 window
-```
-
-Override with `--prefix <dir>`. Test, benchmark and run steps build what they
-need without installing development binaries.
+A bare `zig build` builds both native shells and **installs** them into
+`~/.local/bin` (`pardes`, the terminal shell; `pardes-gui`, the SDL window;
+`pardes-v9fs`, the Tty9p helper). `--prefix <dir>` installs elsewhere, except
+`--prefix zig-out`, which counts as no prefix. With `-Dplatform=<shell>` the
+default prefix is `zig-out`. Test, benchmark and run steps build what they
+need without installing.
-```
-pardes --version e.g. pardes 0.0.1 (e61bbb2e86bd)
-pardes --help every flag
-```
-
-The version comes from `build.zig.zon`'s `.version`; the commit is read from
-`git` at configure time and is simply absent when there is no repository to ask.
-
-## The five platforms
+`pardes --version` prints `pardes <version>`, plus the commit for release
+builds and the `~/.local` install (`-Dstamp-commit=true` forces it).
+`pardes --help` lists every flag.
| build | what it is |
|---|---|
| `zig build` | the terminal shell and the SDL window, together |
| `zig build -Dplatform=tty` | the terminal shell alone |
| `zig build -Dplatform=gui` | the SDL3 window alone |
-| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript, rendered as HTML/CSS |
-| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` |
+| `zig build web -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>` | a freestanding wasm core plus vanilla JavaScript ([docs/web.md](docs/web.md)) |
+| `zig build -Dplatform=macos` | an AppKit and CoreText app over a static `libpardes.a` ([docs/macos.md](docs/macos.md)) |
| `zig build -Dplatform=esp32p4` | a freestanding riscv32 editor object for ESP32-P4, using a 384 KiB heap |
Firmware images are built in the sibling `../05-zig-p4` toolchain, which needs
ESP-IDF register headers. After building the editor object here, `zig build
-Dpardes` there links `src/esp32p4/app.zig`. The separate GPIO 9P image uses
-`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; it does not link the
-editor. Its fixed GPIO namespace is in `src/esp32p4_gpio.zig`.
+`zig build -Dapp=../02-pardes-code/src/esp32p4_9p.zig` there; its fixed GPIO
+namespace is in `src/esp32p4_gpio.zig`.
## Detached sessions
-A shell need not be in the same process as the core.
-
```
pardes --detach=work a core with no terminal of its own
pardes --attach=work become a frontend of it
pardes-gui --attach=work ...the SDL window can attach too
```
-Several frontends may be attached at once and all see the same screen. The
-detached core performs every effect that needs a disk or a process table, so its
-pane shells outlive every frontend; a frontend keeps only what needs the human's
-own display. From inside the editor, `Attach` and `Detach` do the same thing as
-words. See `docs/detached.md`.
-
-## Recent files
-
-A file closed by accident is found again. `Recent` (`SPC f r`) lists the
-files opened lately, most recent first, closed ones too, each marked `open`
-or `closed` at the place its dot was when it closed:
-
-```
-/home/me/src/main.zig:120:5 closed
-/home/me/notes.txt open
-```
-
-A look at a row opens that file there. The list keeps 200 files, each once,
-and lasts across sessions in `$XDG_STATE_HOME/pardes/recent` (else
-`~/.local/state/pardes/recent`). The jumplist keeps a closed file's entries
-too: `Back` to one opens the file again at its place, and `+Jumps` marks it
-`(closed)`. Over 9P, `/recent` reads `open <path>` or `closed <path>` a line.
-acme has nothing like it (its dump and Load are the nearest); pardes goes
-beyond acme here.
+The detached core owns panes, shells and files; frontends come and go. See
+[docs/detached.md](docs/detached.md).
## Tests
@@ -159,130 +110,66 @@ zig build web-driver-test browser driver timeouts and cleanup
zig build web-e2e Chrome-driven DOM end-to-end suite
```
-`snap -- --record=/tmp/captures` writes independent snapshots for comparing
-binaries without changing goldens. `snap -- --update` replaces goldens;
-`snap -- --no-retry` makes the first failure decisive. Retried runs retain
-each attempt's report and mismatching grid in a fresh printed directory.
-For custom differential cases, use
-`zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]`.
-Without a reference, the driver only emits results; exit zero does not mean a
-comparison passed. Custom arguments must include `--strict` to check coverage.
-The benchmark steps — `perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`,
-`lspbench`, `fs-bench` — all accept `-- --json`.
-
-Snapshot scripts can use `snap9p` to capture core cells and styles through the
-default socket alongside the terminal emulator's independent captures.
-
-`zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR] [--keep]`
-writes one random script per seed (keys, leader bursts, typing, clicks, drags,
-wheel, tiny resizes, prompts and long prompt text over bad-UTF-8 and wide-glyph
-files), runs it through pardes-snap and looks for a panic's `.zig:N:N: 0x`
-frame in the report and in the captured grid. A hit keeps the script, its
-captures and its log in DIR (default `/tmp/pardes-monkey`); a seed always makes
-the same script, so `--from=N` with one seed replays it. A trace longer than
-the grid scrolls away: replay the kept script at a larger `start` size. Each
-crash found gets a fix and a regression script in `test/snapshots/`. The tty's
-tagline is the body's pitch, so what a narrower tagline does is fuzzed by a
-unit test instead (`a monkey over notices…` in `src/draw.zig`, longer with
-`PARDES_FUZZ_SEED`/`PARDES_FUZZ_STEPS`).
-
-`-Dtest-filter=<text>` applies to every unit-test binary. Use
-`-Doptimize=ReleaseFast` for performance measurements. To record output and
-first/warm command runtimes across revisions, run:
-
-```
-jj status
-zig build history -- run 'ancestors(@, 2)' /tmp/pardes-history -- zig build unit-test
-zig build history -- compare /tmp/pardes-history/<before>.json /tmp/pardes-history/<after>.json 1.20
-```
-
-The history runner uses `jj run` serially, with recordings outside its isolated
-checkouts without integrating history changes. Run `jj status` before measuring
-`@`: the runner deliberately does not snapshot pending edits. `history record`
-measures current files directly. Add `run --allow-immutable`
-to measure immutable revisions. Each command runs four times; the first run is reported separately
-from the warm median. Standard output and errors are preserved for every run.
-Commands must exist in the selected revisions. The optional comparison ratio
-fails the command when runtime regresses beyond that limit. Add `--snapshots`
-to require stable, identical stdout and stderr, or `--benchmarks` to compare
-individual `syntax-bench` cases, including allocation counts; the ratio limit
-also applies to each case's cold and median time, using medians across the last
-three processes. First-process cold times are reported separately, not gated.
-All four captures must contain the same cases and input sizes. Old revisions
-need the same harness and uncached test execution for meaningful comparisons.
-The recorded Zig version identifies the recorder's compiler; record the child
-toolchain separately when comparing different compilers.
-Recordings are exclusive: use a fresh output directory to repeat a measurement.
-Existing results and partial runs are never overwritten.
-
-`perf -- --base old.json` requires matching build metadata, harness, viewport,
-repetition count, fixtures, and `--only` selection. Reports identify the compiler,
-resolved target/CPU, driver/core/dependency optimization modes, and feature flags.
-Missing or different metadata is rejected; old reports must be regenerated.
-JSON reports omit unmeasured cells. Gesture checks run
-outside the timed interval, and each process owns and removes its fixture directory.
-Hardware, machine load and runtime library versions must be matched separately.
-
-`zig build perf -Dplatform=tty -Doptimize=ReleaseFast -Dtree-sitter=zig -- --mini --json`
-measures braille generation, full highlighting plus generation, and cached Mini
-redraws separately, checking output hashes and allocation balance.
-
-`zig build perf -Doptimize=ReleaseFast -- --terminal-mib 64 --json` streams
-colored, wrapped agent-like output past the terminal history limit. It checks
-the newest text, colors and input, and measures raw redraws, modal movement,
-edit-overlay redraws and memory. Linux RSS comes from the current process image's
-`VmHWM`; allocator counts exclude the terminal library's private mappings.
-
-`python3 -B test/agent_session.py zig-out/bin/pardes --ready 'ready text' --min-rows 20000 -- command args`
-runs a caller-selected interactive command in a private POSIX shell and checks
-its history and an unsubmitted input probe through 9P. Use an owned copy of any
-saved conversation.
-Reports contain counts, hashes and 9P observation times, not conversation text
-or physical keyboard latency. GUI builds need `--gui-grid`.
-
-`test-build -Dtest-rebuild` forces fresh Zig test compilation while retaining
-cached C dependencies. Use a disposable local cache for repeated cold-build
-experiments. Ordinary `unit-test` always runs its tests, even with cached builds.
-`unit-profile` measures the request-to-result interval, including test-runner
-communication, and reports whole-process time and memory separately.
-Filtered test steps fail if no named test matches, even when import guards pass.
-
-The default differential suites require exact case coverage and reject stale
-waivers. Each waiver pins both the reference and editor result. Live Helix
-steps use `-Dhelix-harness=<path>`, `HX_HARNESS`, or `hx-harness` on PATH.
+- `-Dtest-filter=<text>` applies to every unit-test binary; a filter that
+ matches nothing fails. `-Dtest-rebuild` forces fresh Zig test compilation.
+- `snap -- --record=DIR` writes snapshots without touching goldens,
+ `snap -- --update` replaces goldens, `snap -- --no-retry` makes the first
+ failure decisive. Snapshot scripts can use `snap9p` to capture core cells
+ through 9P.
+- `zig build monkey -Dplatform=tty -- <seeds> [steps] [--from=N] [--out=DIR]
+ [--keep]` writes one random script per seed, runs it through pardes-snap
+ and keeps any script that panics (in `/tmp/pardes-monkey` by default); a
+ seed always makes the same script. Each crash found gets a fix and a
+ regression script in `test/snapshots/`.
+- `zig build hxdiff -- --strict cases.jsonl reference.jsonl [waivers.jsonl]`
+ runs custom differential cases; without `--strict` and a reference it only
+ emits results. Live helix steps take `-Dhelix-harness=<path>`,
+ `HX_HARNESS`, or `hx-harness` on PATH.
+- Benchmarks (`perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-bench`,
+ `lspbench`, `fs-bench`) accept `-- --json`; measure with
+ `-Doptimize=ReleaseFast`. `perf -- --base old.json` refuses reports whose
+ build metadata differs.
+- `zig build history -- run 'ancestors(@, 2)' DIR -- zig build unit-test`
+ records a command's output and cold/warm runtimes across revisions (`jj
+ status` first: pending edits are not snapshotted); `history -- compare
+ a.json b.json 1.20` fails past that runtime ratio (`--snapshots`,
+ `--benchmarks` for stricter checks). Output directories are never
+ overwritten.
+- `python3 -B test/agent_session.py <pardes> --ready 'text' --min-rows N --
+ command args` drives an interactive command in a private shell and checks
+ it through 9P (`--gui-grid` for GUI builds).
## Documentation
Start with `src/panes.zig` (and the pane kinds it names, `src/File.zig`,
`src/Terminal.zig`, ...), `src/layout.zig`, and `src/fs.zig` for ownership and
operations, and `src/pardes.zig` for input, with one file per thing beside it
-(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...). `docs/design.typ` is the architecture
-sketch; `docs/design.pdf` is a retained rendering/search fixture and may lag it.
+(`edit.zig`, `normal.zig`, `look.zig`, `exec.zig`, `mouse.zig`, ...).
| file | subject |
|---|---|
-| `docs/design.typ` | architecture: the seams, the data model, the build graph |
-| `docs/detached.md` | one core, many frontends, over a unix socket |
-| `docs/config.md` | build options and runtime configuration |
-| `docs/tags.md` | editable workspace, column and pane tags; filename drafts |
-| `docs/selections.md` | the normal-mode selection model and how it differs from helix's |
-| `docs/fs.md` | default 9P service, Look resolution, and named mounts |
-| `docs/lsp.md` | in-process ZLS and external language servers |
-| `docs/lsp-evaluation.md` | why that backend, measured against the alternatives |
-| `docs/helix-keys.md` | the helix-compatible key model and its differential suite |
-| `docs/macos.md` | the native macOS shell, its bundle and its signing |
-| `docs/web.md` | the browser shell |
-| `docs/ghostty-macos-notes.md` | notes on the ghostty dependency |
-| `docs/ideas.typ` | scratch notes; nothing compiles it, and it says so |
+| `docs/fs.md` | the 9P control filesystem: every file, error and limit |
+| `docs/config.md` | settings, the startup file, dumps, build options |
+| `docs/tags.md` | tags, columns, and where new panes go |
+| `docs/detached.md` | one core, many frontends |
+| `docs/v9fs.md` | Tty9p: a terminal with the tree kernel-mounted |
+| `docs/cloud9.md` | the 9P library and the posted-9P registry |
+| `docs/selections.md`, `docs/helix-keys.md` | the normal-mode model and the helix key map |
+| `docs/themes.md`, `docs/effects.md` | themes and visual effects |
+| `docs/lsp.md` | language servers |
+| `docs/web.md`, `docs/macos.md` | the browser and macOS shells |
+| `docs/design.typ` | architecture sketch (`docs/design.pdf` is a test fixture and may lag it) |
+| `docs/divergences.md`, `docs/open-questions.md` | bookmarks off `main`; undecided questions |
+| other `docs/*.md` | design and research notes (`render-pipeline`, `lsp-evaluation`, `ui-review`, ...) |
-`next-steps.txt` is a wishlist with a status header saying which items have
-shipped; `transactions.txt` records one open structural gap against helix, and
-says which waiver proves it is still open.
+`next-steps.txt` is a wishlist with a status header; `transactions.txt`
+records one open structural gap against helix and the waiver that proves it.
## Layout
```
src/ core events (pardes.zig), panes, edit, look, exec, layout, fs, syntax
+src/ninep/ the 9P control tree
src/detached/ the wire, the detached core, the frontend client
src/lsp/ ZLS and external language-server backends
test/ harnesses, snapshot goldens, helix cases
diff --git a/build.zig b/build.zig
index 8b7cb3b2..e549a53e 100644
--- a/build.zig
+++ b/build.zig
@@ -1622,7 +1622,37 @@ pub fn build(b: *std.Build) void {
if (enable_quic) run_fs_test.addArg("--quic");
run_fs_test.setCwd(b.path("."));
run_fs_test.has_side_effects = true;
- b.step("fs-test", "exercise default 9P service and mounts with an independent client").dependOn(&run_fs_test.step);
+ const fs_test_step = b.step("fs-test", "exercise default 9P service and mounts with an independent client");
+ fs_test_step.dependOn(&run_fs_test.step);
+
+ // Random 9P operations against a throwaway detached session, checking
+ // the documented rules (test/monkey9p.py). The fixed-seed smoke run is
+ // part of fs-test; the long run is manual.
+ const run_monkey9p = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" });
+ run_monkey9p.addArtifactArg(exe);
+ if (b.args) |args| run_monkey9p.addArgs(args);
+ run_monkey9p.setCwd(b.path("."));
+ run_monkey9p.has_side_effects = true;
+ b.step("monkey-9p", "random 9P operations checking the documented rules (-- [--seed N]... [--steps M] [--replay F] [--shrink F] [--no-shrink])").dependOn(&run_monkey9p.step);
+ // The GUI's effects under random input in a hidden test window, each
+ // Motion x Lift x Bloom (test/gui_monkey.py): a gate, not part of
+ // fs-test, since it needs a Wayland display.
+ const monkey_gui_step = b.step("monkey-gui", "random input to hidden GUI test windows across the effect settings (-Dplatform=gui; -- [--seed N] [--steps M])");
+ if (platform == .gui) {
+ const run_monkey_gui = b.addSystemCommand(&.{ "python3", "-B", "test/gui_monkey.py" });
+ run_monkey_gui.addArtifactArg(exe);
+ if (b.args) |args| run_monkey_gui.addArgs(args);
+ run_monkey_gui.setCwd(b.path("."));
+ run_monkey_gui.has_side_effects = true;
+ monkey_gui_step.dependOn(&run_monkey_gui.step);
+ } else monkey_gui_step.dependOn(&b.addFail("monkey-gui needs -Dplatform=gui").step);
+ const run_monkey9p_smoke = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" });
+ run_monkey9p_smoke.addArtifactArg(exe);
+ run_monkey9p_smoke.addArg("--smoke");
+ run_monkey9p_smoke.setCwd(b.path("."));
+ run_monkey9p_smoke.has_side_effects = true;
+ b.step("monkey-9p-smoke", "a short fixed-seed 9P monkey run (part of fs-test)").dependOn(&run_monkey9p_smoke.step);
+ fs_test_step.dependOn(&run_monkey9p_smoke.step);
const run_discovery = b.addSystemCommand(&.{ "python3", "-B", "test/fs.py" });
run_discovery.addArtifactArg(exe);
diff --git a/build.zig.zon b/build.zig.zon
index 877dfacb..5ee65c4b 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.3",
+ .version = "0.0.4",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
diff --git a/docs/cloud9.md b/docs/cloud9.md
index 7365bf65..cd92fd05 100644
--- a/docs/cloud9.md
+++ b/docs/cloud9.md
@@ -1,107 +1,46 @@
-# cloud9 integration
+# cloud9
-The published `cloud9` package owns the base 9P2000 wire format, client and server
-connections, and TCP/Unix/QUIC transports. `build.zig.zon` pins a commit from
-`[email protected]:~gbrls/cloud9`, so `zig build` fetches it into `zig-pkg/` like every
-other dependency; no sibling checkout is required. Re-pin with
-`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, and swap in
-`.cloud9 = .{ .path = "../cloud9" }` while editing both packages at once.
+The `cloud9` package owns the 9P2000 wire format, client and server
+connections, the file-server engine and the Unix/TCP/QUIC transports.
+`build.zig.zon` pins a commit from `git.sr.ht/~gbrls/cloud9`, fetched into
+`zig-pkg/` like any dependency. Re-pin with
+`zig fetch --save=cloud9 git+https://git.sr.ht/~gbrls/cloud9#<commit>`, or
+use `.cloud9 = .{ .path = "../cloud9" }` while editing both.
-The file-server engine (fids, jobs, parking, flush, hangup) is cloud9's
-`fs.Server`; the control tree in `src/ninep/` is its backend, using cloud9's
-`fs.Req`, `fs.Op`, `fs.Status`, `fs.E` and `fs.ReplyWith` (extended with the
-editor's reply payload locator). `src/9p.zig` names the editor's and the
-board's `fs.Options` and re-exports the wire names the transports use.
-Mounting, Unix namespace discovery and permissions, the editor event loop,
-connection limits, and exported tree policy remain here. The Unix and TCP
-listeners run on cloud9's `serve.Runner` (`std.Io`: an accept task per
-listener, a reader and a writer task per connection, four slots); its handler
-answers every backend request on the connection's task, taking the editor's
-turn with the core (`pardes.turn`) while the editor waits for input or is out
-in a syscall. A request that would change a pane while the editor is mid-step
-is parked with `Status.again` -- the engine parks reads, writes, opens,
-truncations, clunks and removes -- and retried by `Runner.wakeAll` when the
-editor next rests. A read with nothing yet parks the same way, but is never
-retried for news: the core holds it (`ctlfs.hold`) and `answerHeld` answers
-it through the ticket `Conn.hold` gave its park, with `Conn.answerWith`,
-which makes the answer only while that very park still waits (not flushed,
-its fid not clunked, not out being retried) and under the same lock. `src/9p_quic.zig` selects the existing `pardes-9p` ALPN for
-cloud9's optional OpenSSL transport; QUIC still runs on the poll loop in
-`src/9p_io.zig` on the editor's thread, since cloud9's QUIC adapter is
-nonblocking-descriptor based rather than `std.Io` based.
-The standalone protocol/GPIO tests import the same module. The separate
-`05-zig-p4` build also supplies cloud9 for the GPIO firmware entry.
+- The engine (fids, jobs, parking, flush, hangup) is cloud9's `fs.Server`;
+ the control tree in `src/ninep/` is its backend. `src/9p.zig` names the
+ editor's and the board's `fs.Options` (msize 8192, 256 fids).
+- Unix and TCP listeners run on cloud9's `serve.Runner` (`std.Io`: an accept
+ task per listener, a reader and a writer task per connection, 16
+ connections). Requests are answered on the connection's task, which takes
+ the editor's turn (`pardes.turn`) while the editor waits for input or is
+ out in a syscall. A request that would change a pane while the editor is
+ mid-step parks (`Status.again`) and is retried when the editor rests. A
+ read with nothing to answer yet is held by the core and answered through
+ the ticket `Conn.hold` gave it, only while that park still waits.
+- QUIC (`src/9p_quic.zig`, ALPN `pardes-9p`) still runs on the editor's
+ poll loop in `src/9p_io.zig`, since cloud9's QUIC adapter is
+ nonblocking-descriptor based.
-Run `zig build 9p-test` for the engine configurations and
-`zig build 9p-io-test -Dquic=true` for native transport/client integration. Run cloud9's `zig build test`,
-`transport-test`, `quic-test -Dquic=true`, `fuzz`, and `differential` steps for the
-shared implementation. See cloud9's `docs/validation.md` for recorded runs and
-known test-environment limits.
-
-Invalid framing now terminates a server connection. Cloud9 also checks reply
-counts and reserves tags until flush completion. Client metadata is slightly
-larger to track those reservations, and its bounds tests reflect that fixed cost.
+Tests: `zig build 9p-test` (engine configurations), `zig build 9p-io-test
+-Dquic=true` (transports and client). cloud9's own `zig build test`,
+`transport-test`, `quic-test -Dquic=true`, `fuzz` and `differential` cover
+the shared code.
## The posted-9P registry
-`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: a server posts itself in it
-under a name, and clients dial names rather than paths. cloud9 owns both
-sides (`cloud9.post`), and `9ns --mntgen` mounts the whole registry at
-`/mnt/9p` for programs that want it as a filesystem. pardes's only part in
-it is to put itself there.
-
-**Serving.** A listening editor advertises itself at
-`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to the socket it already
-binds. One directory for the program, one entry per editor, so several
-editors group instead of crowding the registry root — the layout zmx posts
-its sessions under. The socket itself does not move: adopting the registry
-only advertises. Only the runtime-directory socket posts; an instance that
-fell back to `~/.local/state/pardes` stays out of the user's registry, the
-way a private `ZMX_DIR` does for zmx. Stopping unposts, and only while the
-entry is still ours, so a name another editor has since claimed is never
-unlinked.
-
-An exit that cannot run any code of its own — an aborted test, a kill, a
-crash — leaves its entry and its socket behind, so posting first sweeps the
-group: every entry that is a symlink and whose socket answers a connect with
-a definite ECONNREFUSED is unlinked, along with the socket it points at when
-a `stat` agrees that is a socket of ours. Anything that is not a symlink is
-somebody else's, and any other answer — connected, busy, refused permission,
-a surprise — counts as live, because uncertainty belongs to the server that
-owns the socket rather than to the sweeper. That is `cloud9.post.Probe`'s
-classification, repeated in `src/9p_io.zig` only because `post.probe` is raw
-Linux syscalls and pardes also builds for darwin.
-
-**Consuming.** Nothing. `9ns --mntgen` mounts the whole registry at
-`/mnt/9p`, and an interactive fish already self-wraps in one, so a pardes
-started from a terminal sees every posted service as ordinary files —
-`/mnt/9p/harness/active/...` is read with the same code that reads any other
-path. Teaching pardes to dial the registry itself would put discovery in a
-second place for no gain: mounting is the client's job and 9ns is the
-client. `--mount=<name>=<dial>` keeps meaning exactly what it always did,
-and a dial keeps resolving exactly as it always did — a bare name is another
-pardes session, and anything with a slash is a path, relative ones included.
-
-The one case that is not free: a pardes started outside a mntgen mount has
-no `/mnt/9p`. That is 9ns's problem to solve — by being in the namespace —
-not a reason for pardes to carry its own registry client.
-
-### What this diverges from, deliberately
+`$XDG_RUNTIME_DIR/9p` is this machine's `/srv`: servers post themselves
+there by name, and `9ns --mntgen` mounts the whole registry (default
+`/mnt/9p`). A pardes whose socket is in the runtime directory posts
+`$XDG_RUNTIME_DIR/9p/pardes/<name>`, a symlink to its socket (one directory
+per program, as zmx posts its sessions); a socket that fell back to
+`~/.local/state/pardes` is not posted. Stopping unposts the entry if it is
+still ours. Posting first sweeps the group: an entry that is a symlink whose
+socket refuses a connect (ECONNREFUSED) is removed with its socket; anything
+else counts as live.
-* **pardes binds its own socket; it does not post through `cloud9.post`.**
- `post` would give us its hardened claim protocol (temp-bind plus atomic
- rename under a lock) instead of the stale-socket retry in `listen`, but it
- claims *flat* names only: `legalName` rejects `/`, and `claimName` derives
- its lock directory by stripping `/9p` from the registry path, so a name
- inside a subdirectory cannot go through it. zmx hand-rolls the same
- symlink for the same reason. Unifying them means teaching `post` a group —
- passing the lock directory in rather than deriving it — and that is a
- change to adversarially-hardened code, not a rename.
-* **The registry entry is a symlink, not the socket.** A reader that expects
- every registry entry to be a socket must `stat` following symlinks.
- `9ns --mntgen` and `cloud9.post.dial` both do.
-* **Dialing is untouched.** An earlier draft taught `resolve` to fall back
- to the registry for a bare name and to read `<group>/<name>` as a
- subdirectory entry. Both were reverted: the second reinterpreted relative
- dials, which are a feature, and the first duplicated what 9ns already
- does. `src/9p_io.zig`'s `resolve` is byte-identical to what it was.
+pardes does not dial the registry: `9ns` is the client, and `--mount` dials
+resolve as always (a bare name is a pardes session, anything with a slash a
+path). pardes binds its own socket instead of posting through `cloud9.post`,
+because `post` takes only flat names and pardes posts into a group
+directory. A reader of the registry must `stat` through the symlink.
diff --git a/docs/config.md b/docs/config.md
index 349814a6..f25c8746 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,79 +1,20 @@
-# Startup configuration
+# Configuration
-Native pardes builds use a per-user `pardes` configuration directory. Its
-main command file is named `init`:
+## The startup file
-- Unix: `$XDG_CONFIG_HOME/pardes/init`, falling back to
- `~/.config/pardes/init`.
-- macOS: `$XDG_CONFIG_HOME/pardes/init` when that variable is set, otherwise
- `~/Library/Application Support/pardes/init`.
-- Windows: `%LOCALAPPDATA%\pardes\init`, with
- `%USERPROFILE%\AppData\Local\pardes\init` as the fallback.
-
-On the two unixes `XDG_CONFIG_HOME` counts only when it is ABSOLUTE, as the
-XDG base-directory specification requires; an empty or relative value falls
-back to the home-directory form (`config.User.path`, and the test beside
-it). Windows never consults it. An `init` that does not fit the `max_bytes`
-read limit — 1 MiB, `config.User` in `src/config.zig` — or that cannot be read at all is
-treated as no file: `load` takes the `readFileAlloc` error and keeps going. The
-path still resolves, because "nothing is there yet" is the answer `Config`
-exists to give. There is one case with no path at all: a native launch with no
-`HOME` set (or, on Windows, neither `%LOCALAPPDATA%` nor `%USERPROFILE%`),
-which `Config` reports as `no per-user config path`.
-
-`Config` (`SPC f c`, or the word executed anywhere) opens one refreshable
-`+Config` pane. It reports the startup path and every live config-like value:
-theme, colors, focus tint, syntax weight, wrapping, tag position, debug mode, the requested shell and the
-executable actually resolved at the last spawn, requested/effective GUI font
-and size, tagline scale, window opacity, ligatures (SDL GUI only), panel transition,
-scene effects, hover delay, platform, native-image support, and (on SDL) whether
-the executable uses live-built shaders or the paired prebuilt shader snapshot.
-Platform-dependent rows say `unsupported` instead of looking like an off or
-empty supported setting. The fields of `config.Runtime.Capabilities` gate
-them and are stated once as plain data in `builtins.capabilities`:
-`font_picker` is the SDL GUI and
-native macOS only, `scene_shaders` the same two, `panel_transitions` every
-hosted shell, `window_opacity` the SDL GUI and macOS, `window_blur` macOS
-only, `ligatures` the SDL GUI only, and `tagline_font_size`
-everything but the TTY and the board. `ligatures` is the exception to
-`unsupported`: where it is off, `Ligatures` is not a builtin at all and
-`Config` has no row for it. So
-the TTY reports Font, TaglineSize and the scene shaders as unsupported; the
-browser reports Font, panel transitions and the scene shaders as unsupported,
-and its TaglineSize row reads `82 (build-time only)` — tagline font size is
-its own capability precisely because GUI font SELECTION is native-only while
-the browser still applies the compiled percentage to its DOM glyphs.
-The startup path is printed whether or not a file exists — that is usually when
-it is most useful — and is ordinary selectable text, so a right click on it
-opens the file. Shell follows the same requested/effective/pending model as
-Font. `Default shell` is the one used while no `Shell` is set: `$SHELL`, the
-user's login shell, else `/bin/sh` (also when `$SHELL` names nothing
-executable); an explicit `Shell` overrides it. `Shell <name or path>` is
-refused unless it names an executable file (`Shell: shell "x" not found (...)`, or
-`Shell: not a shell: /etc is a directory`; a bare name is looked for in the
-usual bin directories), as `Tty <shell>` is, and a
-bare `Shell` goes back to the default. The root ctl reads `Shell <the one the
-next terminal runs>`. `Shell
-effective (last spawn)` is the executable the native host really chose after
-installation lookup and fallback. A changed request remains pending until a
-terminal is spawned, because the core does not resolve native executables.
+Native builds read one command file, `init`, from the per-user `pardes`
+configuration directory:
-The mutable global values live together in the plain `config.Runtime` record.
-One plain capability record gates the setting registry, leader table,
-`EffectCode`, and report; the compile-time setting table generates both setter
-builtins and their `Config` rows. Exhaustive checks require every table-backed
-toggle, transition, and scene-effect switch to occur exactly once, so those
-generated setting builtins cannot quietly lose their query row or leave a
-renderer switch unnamed. Manual pane-local actions remain with their payload (for
-example an image tag reports its renderer choices); they are not global
-configuration.
+- Unix: `$XDG_CONFIG_HOME/pardes/init` (only an absolute `XDG_CONFIG_HOME`
+ counts), else `~/.config/pardes/init`.
+- macOS: `$XDG_CONFIG_HOME/pardes/init` when set, else
+ `~/Library/Application Support/pardes/init`.
+- Windows: `%LOCALAPPDATA%\pardes\init`, else
+ `%USERPROFILE%\AppData\Local\pardes\init`.
-The browser build has no local user-config path and does not load this file.
-(Nor does it have `Font`, the effect builtins or `EffectCode`, a language
-backend, or ptys of its own — see `docs/web.md`.)
+The browser build has none. A file over 1 MiB or unreadable counts as absent.
-The format is one existing builtin command per line, using the same spelling
-and argument parsing as commands executed inside pardes:
+Each line is one builtin, spelled as it would be executed in pardes:
```text
Theme orchard
@@ -83,620 +24,171 @@ Shell zsh
Wrap
```
-A line matches a builtin whose name takes NO argument only as that whole word:
-`Kill` runs, `Kill something` does not. A builtin that takes one
-(`takes_arg` in `src/builtins.zig`, or a `settings` row whose action is
-`shell`, `theme`, `font`, `tagline_size` or `window_opacity` in `config.Runtime.settings`) takes
-everything after the name as the argument. On a native build that is `Theme`,
-`ThemeFile`, `Font`, `TaglineSize`, `Shell`, `Save`, `Restore`, `Attach`,
-`Mount`, `Unmount`, `Find`, `Grep`, `Rename`, `WsSymbols`, `Look`, `Exec`,
-`Msg` and `EffectCode`.
-The SDL GUI also has `WindowOpacity`, which takes one argument.
-(`Peek`, `Poke`, `Hexdump` and `Gpio` take one too, but they exist only where
-`builtins.Board.enabled` holds, and that build has no config file.)
-
-`Theme <name>` wants one of the names in the compiled ring. Do not derive the
-spelling — read it off `Themes` (`SPC t t`), which lists every one as the
-exact `Theme <name>` line that selects it. `slug` in `tools/gen_themes.zig`
-lowercases, folds punctuation runs to a single `_` and then TRIMS leading and
-trailing ones (`penumbra+.toml` is `penumbra`, not `penumbra_`), and every
-variant read out of a zed `.json` gets `_zed` on the end so it cannot collide
-with a helix theme of the same name — zed's "Ayu Mirage" is `ayu_mirage_zed`
-and `ayu_mirage` is helix's `ayu_mirage.toml`. The suffix goes on all of them
-rather than only the eight that clash today, so a name cannot move when either
-project gains or loses a file. A name that is not in the ring is ignored.
-
-The fifteen [native Pardes themes](themes.md) lead the ring: `orchard` (the
-default), `dusk`, `ink`, `paper`, `daybreak`, `atelier`, `forge`, `lagoon`,
-`solarium`, `spectrum`, `harvest`, `clay`, `forge_black`, `forge_soft`, and
-`orchard_black`, then `acme` and `lapis`. `Themes` lists them first under
-Pardes themes. They add coordinated
-focus, search, diagnostic and terminal colors; `ink` and `daybreak` are high
-contrast dark and light options. After them come the
-[faithful ports](themes.md#faithful-ports) of well-known themes, a section per
-family, then the legacy `helix` and `dark`, then everything imported. Where a
-port takes an imported theme's name (`dracula`), the imported one gains
-`_helix` (`dracula_helix`), as zed's carry `_zed`. `NextColor` walks the same
-ring in the same order.
-
-`FocusTint` toggles the focused pane and column tag tints; it is on by default.
-Workspace, column and pane command text can be edited directly; see
-[editable tags](tags.md) for naming and saved-workspace behavior.
-The column command row is always shown; an old `ColumnTags` init line is
-ignored with a message.
-`Placement acme` (the default) puts new panes where acme would, in the active
-column; `Placement pardes` brings back pardes's own rules, which open a first
-document in a column of its own. See [where new panes go](tags.md#where-new-panes-go).
-`BootShell keep` (the default) leaves a shell alone when a document is dragged
-into the left column beside it, whatever it holds; `BootShell replace` closes
-such a shell there when it is the column's only one and nobody has typed
-into it (no scrollback, cursor still on the first prompt line), the boot's
-placeholder giving its rows to the document. Bare `BootShell` flips it, as
-bare `Placement` does: a setting that chooses among words (`on`/`off`
-switches, `Placement`, `BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`,
-`ShaderAnimation`) steps to its
-next value when written bare, as its word clicked in a tag does, and a value
-it does not take is refused with the values it takes, which `/commands` also
-lists.
-`Verbose` toggles the message-row announcement every builtin makes of its own
-name before it runs; it is on by default, and the builtins that own the message
-row themselves (`Msg`) never announce. Turning it off leaves the row to the
-messages a builtin chooses to write.
-`MessageAnimation` toggles how a message comes and goes; it is on by default.
-A message eases down into its row, fast at first and settling at the end, its
-colour whole by half way (graphical frontends slide it out from under the
-tagline as it fades up; a terminal only fades it in), stays until the next key or click as it
-always has, then lingers for `MessageLinger` milliseconds (default 800) before
-it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve`
-(default 150: leaving is quicker than arriving) set how long the fall and the dissolve take, also in
-milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with
-`MessageAnimation off` restores the old behaviour, a message cleared by the
-very input that follows it. An updated line on a row already showing one swaps
-its text in place rather than falling again.
-`SyntaxBold` toggles bold syntax keywords; it is off by default. These
-settings are shared by GUI and TTY, and `Config` reports their current
-states. Like `Colors` and `Wrap`, these commands take no argument and invert
-the current value. A `FocusTint` line in a fresh startup configuration disables
-the tint; a `SyntaxBold` line enables the stronger keyword weight. Those two
-appearance controls leave focus, selections and editing behavior unchanged.
-
-`Ligatures` (SDL GUI only) toggles a font's programming ligatures, such as
-Maple Mono's `->` and `!=` drawn across their cells; it is on by default.
-Off, every cell draws its own glyph, exactly as a font without ligatures does.
-`Ligatures on` and `Ligatures off` set it explicitly. The native macOS shell
-draws CoreText ligatures of its own, which this setting does not reach.
-
-`TreeContext` toggles sticky declaration headers for the current source pane.
-It is off by default and appears in the default pane tag when a tree-sitter
-grammar supports that file. `TreeContext on` and `TreeContext off` set it
-explicitly. Scrolling inside a function, type or module keeps its enclosing
-declarations above the body, with source line numbers, syntax colors and a
-subtle background tint. Clicking a header moves the cursor there without
-scrolling; scrolling upward reveals that source line as the headers recede.
-`Dump` and `Restore` preserve the setting per pane. Customized tags retain
-their text; the command can still be executed from any source pane.
-
-`TreeContextTagStyle` toggles the experimental tagline treatment for those
-headers and is on by default. In graphical frontends it uses the tagline font,
-line height and thin border. A matching separator marks gaps between the
-headers' source lines. Turning it off restores body-sized context rows.
-`TreeContext` itself still defaults off; this appearance option does not enable
-it. `Config` reports the appearance option, and dumps preserve it.
-
-Search, Grep and LSP location-result panes include `LocationsConfig` in their
-default tags. Custom tags keep their edits.
-
-Location highlighting is enabled by the command that produces a location list.
-Plain reports, including `LocationsConfig`, keep ordinary text colors even
-when their text resembles a location. `Mini` keeps its own syntax colors.
-LSP references and goto results highlight the exact symbol span supplied by
-the server, using the search-result colors. Source indentation is retained so
-those byte ranges stay aligned; context and descriptive labels remain unmarked.
-
-`LocationsConfig` prints the current settings for Search, Grep and LSP location
-results in an output pane. Execute the printed line to apply it again, or
-supply just the fields to change:
-
-```text
-LocationsConfig context:5 tscontext:on tslocations:off layout:stacked
-```
-
-- `context` is the number of source lines above and below each match (default
- `0`). Overlapping context is shown once. These neighboring lines omit their
- locations and keep their code aligned with the matching result.
-- `tscontext` includes enclosing tree-sitter declaration headers in source
- order (default `off`). Preceding neighboring context stops at the outermost
- enclosing declaration, keeping unrelated lines above it out of the result.
-- `tslocations` shows a location on the first line of each declaration header
- (default `on`); continuation lines keep the same alignment without repeating
- the location. Declaration headers use the same muted color as locations, with or
- without their locations visible.
-- `layout:stacked` (default) puts each location on its own line, followed by its
- source preview. Hidden context locations do not add an empty address line.
- Use `layout:inline` to put locations beside the source instead.
+A builtin that takes no argument matches only as the whole line (`Kill` runs,
+`Kill something` does not); one that takes an argument takes the rest of the
+line. Blank, unknown, malformed or failing lines are ignored silently and do
+not stop later ones. Text that is no builtin is not run as a shell command
+(`Exec ...` still is). Key bindings are compile-time choices in
+`src/config.zig`; `init` does not remap them.
-In inline layout, result locations share padding in groups of eight matches, so a long path only
-widens its own group. Context does not count toward the eight; at group boundaries,
-it aligns with the nearer match (ties stay with the preceding group). Source
-indentation is preserved. All visible locations start flush left; `n` and `N`
-still stop on matches.
-With `tscontext` enabled, asterisks after a match location show its declaration
-depth (for example, `main.zig:42 ***`). Ordinary neighboring source lines retain
-syntax colors; declaration headers do not.
-A declaration context line ends with ` ...` when source lines are omitted
-before the next displayed row from that file.
-Source analysis is reused across result queries while the source bytes stay the
-same. The cache retains at most 64 files and 64 MiB; it checks current buffer or
-filesystem contents on each refresh.
-Open file buffers supply context from their current edits. Missing files still
-leave the original result available. These settings apply to subsequent result
-generation and survive `Dump`/`Restore`. Invalid fields reject the entire
-update; if a field appears twice, its last value wins.
+`Config` (`SPC f c`) opens a `+Config` pane with the startup path (a right
+click opens it) and every live setting; a setting the frontend cannot show
+reads `unsupported`. The root `ctl` file reads the settings back in the
+words a write takes ([fs.md](fs.md#the-root-ctl)).
-`WindowOpacity <percent>` controls the opacity of everything in the SDL window
-except text and the cursor, which stay fully opaque. Use `WindowOpacity 85`
-to see the desktop through the editor, or `WindowOpacity 100` to restore full
-opacity (the default). The argument must be a whole number from
-0 through 100; missing or invalid values
-leave the setting unchanged. At zero only text and the cursor remain visible.
-Add the command to `init` to persist it.
+## Settings
-The same opacity applies to editor and embedded terminal backgrounds, UI
-chrome, borders, scrollbars, gutters, and images. Overlapping non-text drawing
-does not make those areas more opaque. Regular text, syntax colors, tagline
-text, terminal glyphs, and the cursor keep their normal opacity. This does
-not blend foreground colors into their cell backgrounds. The TTY
-and other non-SDL hosts do not emulate this effect; their `Config` report says
-`WindowOpacity unsupported`.
+A setting that chooses among words (`on`/`off` switches, `Placement`,
+`BootShell`, `Crt`, `Bloom`, `Vignette`, `Grain`, `Lift`, `Motion`,
+`ShaderAnimation`) steps to its next value when given bare, as its word
+clicked in a tag does; a value it does not take is refused, naming those it
+does. `/commands` lists every builtin and its values.
-On native Wayland, Pardes uses an alpha-capable transparent surface. It does
-not use whole-window opacity protocols such as `wp_alpha_modifier_v1`, because
-those would also fade the text. Presentation uses the existing GPU offscreen
-renderer followed by a readback and SDL renderer upload per presented frame,
-which adds rendering cost. Other SDL drivers can report background transparency
-as unsupported unless their rendering configuration is alpha-capable.
-If the rendering backend cannot apply the request, Pardes reports the error
-and keeps the last successfully applied opacity. `Config` reports the
-percentage and marks a request as pending until the SDL host handles it.
-
-## Crash records
-
-A panic appends to `crashes` in that same directory, beside `init`, and only
-then prints to stderr (`src/crash.zig`, wired into the panic handlers in
-`main.zig` and — because the macOS build roots there — `macos.zig`). stderr is
-the one place this program cannot keep a trace: in the TTY shell stderr IS the
-screen, so the trace lands on a grid the terminal is being reset out of; the SDL
-and AppKit shells have no terminal at all; and a `--detach` session's stderr
-goes wherever its launcher left it. The file is appended, never rewritten, and
-each record is two lines — build metadata, then the panic message:
-
-```text
-pardes 0.0.2 (a1b2c3d) 2026-09-03T11:20:44Z linux-x86_64 pid 48812
-panic: index out of bounds: index 4, len 4
-```
-
-NO STACK TRACE, and that is a measured decision rather than an omission. The
-frames stay on stderr, where `std.debug.defaultPanic` prints them. Collecting
-them here instead HANGS the process: `writeCurrentStackTrace` called from a
-panic handler before `defaultPanic` has run wedges at 0% CPU, and
-`captureCurrentStackTrace` — which looks like the safe half — takes `SelfInfo`'s
-rwlock exclusively on its first call, so a panic inside the walk leaves that
-lock held and `defaultPanic` then waits on it forever. What makes `defaultPanic`
-survive the same hazard is its own private `panic_stage`, which nothing outside
-`std.debug` can reach. A crash that becomes a hang is worse than the crash, so
-this file keeps only what it can gather without asking the process any
-questions: which build, when, where, and what it said.
-
-Everything about it is best effort and silent: no config directory (a launch
-with no `HOME`) means no file, and a directory that cannot be created or opened
-leaves the panic exactly as it was before — stderr alone. The directory itself
-is created if it does not exist, because the user who never wrote an `init` is
-as likely as any other to hit a bug. One record at a time: two threads panicking
-at once would otherwise interleave into one buffer, so the second falls straight
-through to stderr. Only panics come here; a SIGSEGV is caught one level lower
-(`main.zig`'s `debug.handleSegfault`) and unwinding one needs the signal's saved
-CPU context.
-
-## Runtime theme files
-
-`ThemeFile <path>` loads one complete theme from a `.zon` file. An absolute
-path is used as written; a relative path is resolved from the `pardes`
-configuration directory, not from the process working directory. A typical
-layout is:
-
-```text
-~/.config/pardes/
-├── init
-└── themes/
- └── mine.zon
-```
-
-and the corresponding init line is:
-
-```text
-ThemeFile themes/mine.zon
-```
-
-After a successful load, hosts with document live reload watch the path with
-the same parent-directory mechanism, so in-place writes and editor-style
-rename-over saves reload the theme live. A malformed or incomplete save does
-not replace the last valid theme; fixing and saving the file applies the next
-valid snapshot. Selecting a compiled theme with `Theme <name>` or `NextColor`
-stops the custom-file watch.
-
-Execute `DumpThemes` to write every theme compiled into the executable to:
-
-```text
-<config directory>/themes/builtin/<name>.zon
-```
-
-The command replaces those generated reference files but leaves unrelated
-files alone. Copy one into `themes/`, rename it, change its `.name`, and use it
-as the starting point for a custom theme. The dumped file is also the complete
-format, and it is `pardes.Theme` serialised by `std.zon.stringify`: the theme
-name, thirteen required RGB roles (`sel_bg`, `sel_fg`, `tag_bg`, `tag_fg`,
-`box`, `box_dim`, `kw`, `str`, `num`, `comment`, `lineno`, `scroll_track`,
-`scroll_thumb`), nullable `bg`/`fg`, and either a 16-color RGB `palette` or
-`null`. Optional nullable RGB roles extend this format:
-`tag_active_bg`, `tag_active_fg`, `tag_name_fg`, `tag_active_name_fg`, `border`, `empty_col`, `lineno_active`, `search_bg`, `search_fg`,
-`diagnostic_error`, `diagnostic_warning`, `diagnostic_info`,
-`diagnostic_hint`, `tag_sel_bg`, `tag_rule`, `box_border` and `box_dirty`;
-`sweep_bg` and `sweep_fg` take three triples, and `rule_px` and `rail_px` a
-pixel count. Missing new roles use backward-compatible defaults, so
-previously exported files remain valid. [Theme customization](themes.md)
-describes each role and its fallback. RGB values are three-byte arrays, and
-hex literals are accepted. The original fields remain required; there is no
-inheritance or partial override layer.
-
-`tag_name_fg` gives the filename at the end of a pane's path a separate
-foreground. `tag_active_name_fg` can adjust that tint for active tags; it falls
-back to `tag_name_fg`. When both are omitted or `null`, the filename uses
-the corresponding tag foreground. All canonical themes use a different hue
-at similar perceived brightness to the surrounding text in both states.
-Directory text and tag commands retain
-their regular colors; selecting filename text uses the selection colors.
-Terminal tags use this color for `Tty`, which comes immediately after the path.
-
-`Filter` in a terminal's tag projects that pane's ANSI colors through the
-active theme, and does it in two stages. The default foreground and background
-roles are mapped FIRST, because ghostty-vt generates the whole 256-color
-projection from that pair; every other color follows, by reducing it to its
-nearest canonical xterm key and reading the key back out of the projection.
-
-That reduction compares RGB triples, so it knows about hue and nothing about
-the page — and the projection's cube corners ARE the two anchors, which is how
-a foreground used to end up painted the exact color of the paper behind it
-(`\x1b[38;2;255;255;255m` on acme's `#ffffea`, and the ANSI black a shell
-writes with `\x1b[30m` on either dark theme). So a foreground additionally has
-to keep `tty_filter_min_contrast` — a WCAG ratio, `1.5` by default — against
-the mapped background. One that cannot is not mapped: it takes whichever of
-the theme's own two anchors is still visible on that background. Backgrounds
-are exempt, since a background is the page the floor is measured against. Set
-the constant to `1.0` to accept every projected color, collapses included.
-
-`Shell <name>` sets the binary that the NEXT terminal pane execs; panes
-already open keep the shell they are running. A bare name is resolved against
-the handful of directories a shell actually lives in, not `$PATH`.
-
-On Linux, `Tty9p` (`SPC n 9`) starts that shell with a private kernel 9P mount,
-asking sudo inside the new terminal. `$PARDES_MOUNT` names the mountpoint.
-The installed `pardes-v9fs` helper lives beside the editor; development builds
-can set `PARDES_V9FS_HELPER` to its absolute path. See [v9fs.md](v9fs.md).
-
-Ctrl-B switches between raw TTY and editor mode. Plain Escape at a detected
-shell prompt hops back to the previous pane. Other keys, including Ctrl-O,
-Ctrl-W and modified Escape, belong to the child. Use `Mode` in the pane tag to
-return to editor mode in place. Desktop paste events still feed the terminal.
-
-The paste chords are the exception the window keeps. Ctrl-V types the yank
-register at the program, and Ctrl-Shift-V asks the desktop for its clipboard
-and types that; both go through the program's bracketed paste when it has
-asked for one. Neither reaches the child as a keystroke, so an application
-that would otherwise answer Ctrl-V by reading the system clipboard itself
-never gets the chance to read the wrong thing.
-
-`Font` and `Fonts` exist ONLY in the SDL GUI and native macOS builds — a
-terminal's font belongs to its emulator and a browser's to the page — so a
-`Font` line is one of the silently-ignored ones everywhere else. Both builds
-resolve the name by walking the font directories on every lookup, so a face
-installed a moment ago is findable.
-
-Use `Font <name>:<size>` to change face and size together, for example
-`Font MartianMono-NrRg:18` in the startup file or an editable tag. Fractional
-sizes such as `:18.5` are supported; the accepted range is 8–72 (pixels in SDL,
-points on macOS). `Font <name>` without a suffix preserves the current size.
-Invalid sizes or unknown faces leave the current font unchanged.
-
-The SDL GUI also has a tiny optional workspace-tag companion: `Pet cat`,
-`Pet frog`, or `Pet off` (the default). Its original pixel sprite walks and
-idles in the trailing blank area of the global tag only. It hides while that
-tag is being edited, when the tag is full, or when the font leaves too little
-room; it never replaces text, receives clicks, or appears in pane/column tags.
-Animation runs at ten steps per second, uses theme ink, and requires no image
-assets or shaders. Put `Pet cat` in the startup configuration to keep it;
-`Pet off` disables the companion and its animation. Other hosts ignore the
-startup command. `Config` reports the current choice in SDL.
+| setting | default | |
+|---|---|---|
+| `Theme <name>` | `orchard` | names as `Themes` (`SPC t t`) lists them ([themes.md](themes.md)); `NextColor` walks the ring |
+| `ThemeFile <path>` | | a `.zon` theme, relative to the config directory; reloads live when saved |
+| `FocusTint` | on | tint the focused pane's and column's tags |
+| `SyntaxBold` | off | bold syntax keywords |
+| `Verbose` | on | a builtin announces its name on the message row |
+| `MessageAnimation` | on | messages ease in and dissolve |
+| `MessageLinger`, `MessageFall`, `MessageDissolve` | 800, 180, 150 | milliseconds, at most 60000 |
+| `Placement acme\|pardes` | `acme` | where new panes go ([tags.md](tags.md#where-new-panes-go)) |
+| `BootShell keep\|replace` | `keep` | `replace` closes the untouched lone shell a dragged document lands beside |
+| `LookWord search\|list` | `search` | a looked-at word selects its next place, or lists all in `+Search` |
+| `Shell <name or path>` | `$SHELL`, else the login shell, else `/bin/sh` | the shell the next terminal runs; a bare name is looked for in the usual bin directories, not `$PATH`; bare `Shell` returns to the default |
+| `DumpDir <dir>` | `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes` | where `Dump` writes; `~/` is home; bare returns to the default |
+| `TreeContext` | off | sticky declaration headers in a source pane (per pane, dumped) |
+| `TreeContextTagStyle` | on | draw those headers in the tagline style |
+| `LocationsConfig ...` | | Search, Grep and LSP result layout (below) |
+| `Wrap`, `Colors`, `Tagbottom`, `Debug` | | toggles |
+| `Font <name>[:<size>]`, `Fonts` | | SDL and macOS only; size 8-72 (pixels in SDL, points on macOS) |
+| `TaglineSize <1-100>` | 82 | tagline face, percent; SDL and macOS |
+| `WindowOpacity <0-100>` | 100 | SDL only: everything but text and the cursor |
+| `Ligatures` | on | SDL only; macOS draws CoreText's own |
+| `Pet cat\|frog\|off` | off | SDL only: a sprite in the workspace tag's blank space |
-`Font` is asynchronous at the renderer boundary. `Config` therefore keeps
-requested name/path/size, pending state, and the effective face/point-or-pixel size
-as separate facts; a failed request never gets reported as the face on screen.
-Taglines use a distinct face size in both native GUI renderers. Execute
-`TaglineSize <percent>` to change it live, for example `TaglineSize 70`; the
-accepted range is 1 through 100 and the default comes from
-`gui_tagline_font_percent` in `src/config.zig` (82). The native renderer
-remeasures both the glyph and its visible tag band while retaining body-grid
-pane geometry. Graphical tag text uses the smaller face's measured monospace
-advance and fills the available pixel width, including workspace and column
-tags. Text selection and scrolling use that same capacity; drag grips retain
-their physical body-grid width and position. The 100% ceiling is
-deliberate: a tagline remains exactly one logical grid row, so a larger face or
-band would overlap its pane body or a neighbour instead of leaving the body
-grid stable. `Config` reports the active percentage. The browser applies the
-same compiled percentage to its DOM glyphs but has no runtime setter.
+`Tty9p` (`SPC n 9`) is described in [v9fs.md](v9fs.md); the
+`PARDES_V9FS_HELPER` variable points development builds at the helper.
-Both native GUIs separate the reduced-height global and pane tagline bands with
-a `gui_topbar_pane_border_px` physical-pixel rule. Set it to zero to leave it out.
-`gui_topbar_pane_border_rgb` can pin an RGB color; its default `null` follows
-the theme's `border` role in SDL (falling back to `scroll_track` for older
-themes), and `scroll_track` in the macOS shell. Every band is centred in its
-row, so the workspace, column and pane tag text share one baseline offset and
-the anchors get the same margin above, below and to the left. With `Tagbottom`
-enabled, a tagline on the final grid row is bottom-aligned so the unused
-half-band does not show beneath it. The rule lives in the core
-(`pardes.taglineBandOffset`), and the macOS shell reaches it over the C ABI
-rather than keeping its own copy. When the window is not a whole number of cells,
-bands and rules at the right and bottom edges run on through the leftover pixels.
+### Terminals
-What happens to a codepoint the chosen face has no glyph for differs by shell.
-The SDL GUI falls back through a chain it builds itself: embedded Adwaita
-Mono, then installed `NotoSansMono-Regular`, `DejaVuSansMono`,
-`SymbolsNerdFont-Regular`, `NotoSansSymbols2-Regular`,
-`NotoSansSymbols-Regular`, and `DejaVuSans`, in that order, missing entries
-skipped; the rasterized glyphs are retained in its GPU atlas. The macOS shell
-has none of that and needs none. It embeds no font. Its default face is
-`NSFont.monospacedSystemFont`, resolved through the descriptor rather than by
-name, and `Menlo` only if that face turns out not to be fixed-pitch — a
-proportional face in a fixed grid is a broken screen, not a cosmetic problem
-(`PardesView.defaultFace`). A `Font <name>` arrives as a PATH the core already
-resolved by walking the font directories, and a file CoreText cannot measure
-leaves the face already on screen rather than substituting one, because the
-alternative is a terminal with no way back out (`PardesView.fromFile`).
-Per-codepoint fallback is CoreText's own cascade at draw time. What the shell
-caches is `CGGlyph` ids, not pixels.
+Ctrl-B switches a terminal between raw input and editor mode. Plain Escape
+at a detected shell prompt hops back to the previous pane; other keys,
+Ctrl-O, Ctrl-W and modified Escape included, go to the program. `Mode` in the
+tag returns to editor mode in place. Ctrl-V types the yank register and
+Ctrl-Shift-V the desktop clipboard, through bracketed paste when the program
+asked for it; neither reaches the program as a keystroke.
-Blank, unknown, malformed, or unsuccessful lines are ignored silently, and a
-bad line does not prevent later lines from running. Top-level text that is not
-a builtin is not sent to a shell. (`Exec ...` remains an ordinary builtin and
-therefore keeps its normal behavior.) Key bindings remain compile-time choices
-in `src/config.zig`; this startup file does not remap them.
+`Filter` in a terminal's tag maps its ANSI colours through the theme,
+keeping each foreground at least `tty_filter_min_contrast` (WCAG 1.5, in
+`src/config.zig`) against its background.
-## Panel and scene effects
+### Location results
-Exactly one panel transition is selected at a time. Executing its builtin a
-second time turns it off; selecting another replaces it:
+`LocationsConfig` with no argument prints the current settings as a line
+that can be run again; with fields it changes only those:
```text
-PanelSlide
-PanelZoom
-PanelDissolve
-PanelAscii
-PanelVertical
-PanelEdges
-PanelFall
-PanelWave
-PanelCurtain
-PanelScramble
-PanelType
+LocationsConfig context:5 tscontext:on tslocations:off layout:stacked
```
-All panel transitions start off. To disable one, execute its builtin again:
-`PanelDissolve` turns off an active dissolve, and `PanelAscii` turns off an
-active ASCII transition. `Config` shows the active command under
-`Panel transition`. Remove that command from your startup configuration to
-keep it off after restarting. Executing a different transition enables that
-one instead; these commands are toggles, not an unconditional animation-off command.
+- `context` (0): source lines shown above and below each match.
+- `tscontext` (off): include the enclosing tree-sitter declaration headers.
+- `tslocations` (on): show a location on each declaration header.
+- `layout` (`stacked`): the location on its own line; `inline` puts it
+ beside the source, padded in groups of eight matches.
-Slide uses cubic ease-out and zoom uses an
-overshooting ease-out-back. Dissolve and ASCII compare the last successfully
-presented grid with the new one. Dissolve switches visually changed cells at
-stable noise thresholds.
-ASCII walks every changed single-byte printable glyph
-from its old `u8` value to its new one, spending that byte distance as the
-frames of the walk. The walk is eased in and out: a glyph creeps at both ends
-and crosses the middle of its distance in a few large skips, inside the same
-number of frames a constant one-value-per-frame walk would have taken.
-Glyph-stable style changes
-and non-ASCII graphemes become canonical immediately. A walk is capped at
-twelve movement frames, and each pane lasts only as long as its longest walk.
-The core computes and composes that semantic diff once for every backend;
-pixel attachments, which have no character value, pass through unchanged.
+An invalid field rejects the whole line; a repeated field's last value wins.
+The settings survive Dump and Restore. Source analysis is cached for 64
+files and 64 MiB.
-Six further transitions are character *motion* over the same frozen/new grid
-pair, and are composed in the core the same way:
+### Effects
-- `PanelEdges` — whole rows slide in from alternating screen edges.
-- `PanelFall` — columns rain down into place, each with its own head start.
-- `PanelWave` — a vertical ripple travels across the pane and decays.
-- `PanelCurtain` — a wipe, left to right, behind a soft edge two cells wide.
-- `PanelScramble` — every cell churns through printable ASCII and locks onto
- its final glyph at its own stable noise threshold.
-- `PanelType` — reading-order reveal with a caret sitting on the write head.
+Panel transitions, one at a time; running the active one again turns it
+off: `PanelSlide`, `PanelZoom`, `PanelDissolve`, `PanelAscii`,
+`PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`,
+`PanelScramble`, `PanelType`. All start off; the web shell has none.
-Unlike dissolve and ASCII these carry *every* glyph in the pane, changed or
-not: text flying in from a screen edge has to bring its unchanged glyphs with
-it. A cell whose glyph has not arrived shows the frozen old cell rather than a
-blank or a blend, so every intermediate frame is made of real characters. No
-cell is a valid input target until its own glyph has settled.
+Scene passes (SDL GUI, and a GUI attached to a detached session), each at a
+level 0-3 (`on` is 2), all off by default: `Crt`, `Bloom`, `Vignette`,
+`Grain`. `Shader <file.glsl>` adds a Shadertoy file written for ghostty to
+the chain (`Shader off` removes it; it recompiles when saved);
+`ShaderAnimation off|on|always` says when the chain animates by itself.
-Vertical is a pane-lifecycle effect: a newly added
-pane rises from below inside its own fixed box, and a deleted pane's frozen
-content drops back down; surviving panes are never animated. The TTY
-implementation performs its remaining geometry/dissolve operations directly
-on a copy of the core-composed presentation grid.
-The SDL and native macOS GUI implementations pass plain panel tracks to their
-GPU shaders, including native image/PDF pixels; layout itself commits
-immediately and remains the one authoritative geometry. DOM web intentionally
-does not expose these builtins: its renderer is selectable HTML/CSS and has no
-canvas or shader stage.
+The focused pane can stand off the page: `Lift shadow|rim|auto|off`,
+`InactiveDim <percent>`, `Motion off|crisp|smooth|bouncy|playful`
+(default `smooth`), `SelectionGlow`, `HoverGlow`, `Occlusion`, `Parallax`,
+`JumpTrail`, `ChipShadow`, `ThumbFlash`, `CursorBlink`, `GripWidth <50-300>`.
+Most are GUI-only; `InactiveDim` works everywhere, and `JumpTrail`,
+`ChipShadow` and `ThumbFlash` are the terminal's. No effect may lower the
+contrast of text, the selection or a focus indicator
+([effects.md](effects.md)).
-The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2:
+`EffectCode <effect>` lists that effect's sources under `/virtual` when the
+build embeds them (`-Dembed-sources=true`). `zig build shaders` refreshes
+the committed SPIR-V with its GLSL.
-```text
-Crt
-Crt 3
-```
+### Look preview
-The SDL GUI runs it as the bundled pass of its post chain, which also takes
-Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`;
-a file compiles again when it is saved, and a save that fails to compile
-keeps the last good one and says why), and
-`ShaderAnimation off|on|always` says when the chain animates on its own. A
-GUI attached to a detached session runs the session's chain the same way. With
-the chain empty the pass is bypassed. CRT works in linear light with restrained
-scanlines, mask, bloom and vignette, and no curvature, so clicks land where
-they are drawn. Its slow hum and dither move on their own, so with Crt on the
-GUI keeps drawing while idle (under `ShaderAnimation on`, while focused);
-the other bundled passes are still and let it rest.
+Resting the pointer on text for about 32 ms (`look_preview_delay_frames`, 2
+frames) tints what a right click would look at, with no other effect. Set
+`look_preview_delay_frames` to `null` in `src/config.zig` to turn it off.
-Three more bundled passes take the same levels, each off by default and
-each costing nothing while off:
+## Themes from files
-```text
-Bloom 2 the brightest ink glows a little (only what is brighter than
- the page; a dual Kawase blur at half size and down)
-Vignette 2 the window's corners fall a little into shade
-Grain 2 the page's own ground takes a fine, still grain, like paper
-```
+`ThemeFile themes/mine.zon` loads a complete theme; a malformed save keeps
+the last good one, and `Theme <name>` stops the watch. `DumpThemes` writes
+every compiled theme to `<config dir>/themes/builtin/<name>.zon`: copy one,
+change its `.name`, and edit. The format is `pardes.Theme` as
+`std.zon.stringify` writes it; [themes.md](themes.md) describes each role.
-None of them touches a tag, a grip, a notice or the cursor, and each is
-capped per theme so text and the selection keep their own contrast or 4.5
-(docs/effects.md).
-
-The focused pane can stand off the page (SDL GUI, off by default):
-
-```text
-Lift shadow a soft drop shadow, on the other panes' bodies only
-Lift rim a hairline just above the focused tag (light, or shade on a light page)
-Lift auto a shadow on a light page; on a dark one the others recede
-Lift off
-InactiveDim 30 the unfocused panes' text fades 30% toward its ground
-Motion smooth off, crisp, smooth (the default), bouncy or playful
-SelectionGlow on a soft halo of the selection's colour round it in the body,
- fading in over 100 ms, never over a tag, a grip or the cursor
-HoverGlow on a soft glow under the word a look-hover would open, fading
- in over 80 ms
-Occlusion on pane bodies darken faintly toward their edges (2%), never
- over the cursor
-Parallax on a theme's page pattern (lapis's dots) moves with the text
- at a quarter of its speed
-JumpTrail on in a terminal, a jump of the cursor of three cells or more
- leaves a trail of a few cells that fades in 120 ms
- (truecolor terminals; a pixel shell glides instead)
-ChipShadow on in a terminal, a notice chip casts a shadow a cell right and
- down: half blocks on blank cells, a darker ground on text
-ThumbFlash on in a terminal, a pane's scroll thumb brightens as it scrolls
- and fades back over 250 ms
-CursorBlink on the cursor blinks, solid while typing and half a second
- after, eased at each edge, solid after 10 idle seconds
-GripWidth 150 the grip's button and the scrollbar under it, percent of the
- theme's rail_px (12px at a 17px tagline; 50 to 300)
-```
+## Dumps
-`InactiveDim` works everywhere (a grid shows it at once) and never takes a
-pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30
-while unset. `Motion` sets how every such effect moves: see docs/effects.md.
+`Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`, creating the
+last directory if it is missing (a missing parent fails `no such
+directory`); `$PARDES_DUMP` overrides the file. `Restore <path>` looks for a
+relative path in `DumpDir`, then in the directory pardes started in; bare
+`Restore` takes the last dump this session wrote. `pardes -l <dump.zon>`
+starts from one.
-`EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's
-build-embedded source paths under `/virtual`. Look opens each full file;
-no checkout is needed, but the build must carry them (`-Dembed-sources=true`,
-off by default); otherwise the command reports them unavailable. TTY exposes grid transitions, native GUI builds also
-expose scene shaders, and web has neither. Shared implementations share paths.
-SDL reports whether GLSL was compiled during this build or came from the
-`-Dprebuilt-shaders` snapshot paired with the committed SPIR-V.
-`zig build shaders` refreshes both files of every pair together,
-so editing live GLSL without that explicit refresh changes neither half of a
-prebuilt executable.
+A dump keeps panes, columns and their tags, each text pane's selection, the
+theme, and the settings that differ from a fresh session's; the font stays
+the frontend's. A terminal comes back with its last MiB of output as
+history, a `── restored history ──` line, and a new shell in its old
+directory; a command pane comes back finished (`exit ?` if it was running).
+Undo history and REPL bindings are not kept. A dump holds at most 6 columns.
-## Delayed Look preview
-
-The preview is enabled by default. Moving the pointer onto selectable text and
-leaving it still for `look_preview_delay_frames` — 2 animation ticks, and
-`animation.frame_ms` is 16, so about 32 ms — paints a subtle theme-derived
-preview of the exact operand a right-click Look would receive.
-Repeated motion reports in the same semantic grid cell do not restart the
-delay. The preview uses the same side-effect-free word/path expansion as Look;
-it does not focus a pane, move a cursor, install a selection, activate a PDF
-page, or execute anything. Motion to another operand, pointer leave, input,
-pane teardown, and relevant content changes cancel it.
-
-Set this compile-time option in `src/config.zig` to disable the feature:
+## Crash records
-```zig
-pub const look_preview_delay_frames: ?u16 = null;
-```
+A panic appends two lines to `crashes` beside `init` (build, time, platform,
+pid; then the panic message) before printing to stderr. There is no stack
+trace in it: collecting one from a panic handler can hang the process. The
+trace stays on stderr.
-## Build-time configuration
+## Build options
-Runtime settings live in `src/config.zig`. Build options are listed below;
-`zig build --help` lists the options available for the selected platform,
-including the standard `-Dtarget` and `-Doptimize` options.
+`zig build --help` lists the options for the selected platform.
| option | values | default |
|---|---|---|
-| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent builds the tty cli and the SDL gui together |
+| `-Dplatform` | `tty`, `gui`, `web`, `macos`, `esp32p4` | absent: the tty and SDL shells together, installed into `~/.local` |
| `-Dstatic` | bool | `false` |
-| `-Dquic` | bool; 9P over QUIC using system OpenSSL 3.6+ | `false` |
-| `-Dmupdf` | bool | on for a native target, off for web and esp32p4 |
-| `-Djpx` | bool | `true` — JPEG 2000, and with it scanned PDFs |
+| `-Dquic` | bool; 9P over QUIC with system OpenSSL 3.6+ | `false` |
+| `-Dmupdf` | bool | on natively, off for web and esp32p4 |
+| `-Djpx` | bool; JPEG 2000, and with it scanned PDFs | `true` |
| `-Dtree-sitter` | `disabled`, `zig`, `minimal`, `full` | `full` natively, `zig` for web, `disabled` for esp32p4 |
-| `-Dtheme-animation` | bool | on everywhere except `-Dplatform=esp32p4` |
-| `-Dworkspace-tag` | bool; draw the workspace tag row — the macOS shell hands its commands to the native menu bar instead | on except on `-Dplatform=macos` |
-| `-Dprebuilt-shaders` | bool | on for a bare `zig build`, off when `-Dplatform` names a shell |
-| `-Dtracy` | path to a Tracy source checkout | off |
+| `-Dembed-sources` | bool; serve the sources under `/src` | `false` |
+| `-Dstamp-commit` | bool; the git commit in `--version` and crash records | on for release builds and the `~/.local` install |
+| `-Dtheme-animation` | bool | on except for esp32p4 |
+| `-Dworkspace-tag` | bool; draw the workspace tag row | on except for macOS, whose menu bar carries it |
+| `-Dprebuilt-shaders` | bool; embed the committed SPIR-V | on for a bare `zig build`, off with `-Dplatform` |
+| `-Dtracy` | path to a Tracy checkout | off |
| `-Dmacos-identity` | codesigning identity for `pardes.app` | `-` (ad-hoc) |
| `-Ddump` | a `dump.zon` to embed in the web shell | none |
-| `-Dtest-filter` | substring; run only tests whose name contains it | none |
-| `-Dtest-rebuild` | bool; force fresh Zig test compilation, retaining cached C dependencies | `false` |
-| `-Dhelix-harness` | native reference executable for live differential tests | `HX_HARNESS`, otherwise `hx-harness` on PATH |
-| `-Desp32p4-cols` | u16, the board's grid width in cells | `56` |
-| `-Desp32p4-rows` | u16, the board's grid height in cells | `14` |
-
-The local board build emits an object. Firmware clock, serial port and profiling
-options belong to the sibling `05-zig-p4` toolchain's build.
-
-Two build inputs reach the running binary as ordinary values rather than as
-behaviour. `build.zig` reads `.version` from `build.zig.zon` through an untyped
-`@import("build.zig.zon")` — one place to bump — and `gitCommit(b)` reads the
-revision at configure time. Both land in `pardes_config` and are re-exported as
-`pardes.version` and `pardes.commit`, and `pardes --version` prints
-`pardes <version> (<commit>)`, or `pardes <version>` alone when there is no
-commit: a tarball, a container with no `git`, or a checkout outside version
-control all yield null, and the flag has to work anyway. Neither is a question
-asked at runtime — a binary that shelled out to `git` would describe whatever
-tree it was standing in rather than the one it came from.
-
-`Restore a.dump` first looks for the relative path in the dump directory:
-`DumpDir <path>` when set (a leading `~/` is your home; bare `DumpDir` returns
-to the default), else `$XDG_DATA_HOME/pardes` or `~/.local/share/pardes`.
-`Config` reports the directory in effect as `DumpDir <path>`, so the line can
-be fed back as configuration. If it is absent, Restore
-uses the argument as a path as before. Absolute paths and argument-free Restore
-retain their existing behavior. `Dump` still honors `$PARDES_DUMP` and otherwise
-writes a timestamped file in the default directory.
-
-A dump keeps each text pane's dot (its selection, or the caret), as acme's
-keeps a window's q0 and q1, and a Restore puts it back where the kept view
-shows it. It keeps the settings that differ from a fresh session's (`Placement
-pardes`, `Verbose off`, a shader), as the root ctl reads them, and a Restore
-sets them again; the theme is kept with the layout, and the font stays the
-frontend's. REPL bindings are not kept.
+| `-Dtest-filter` | run only tests whose name contains it | none |
+| `-Dtest-rebuild` | bool; fresh Zig test compilation | `false` |
+| `-Dhelix-harness` | reference executable for live differential tests | `HX_HARNESS`, else `hx-harness` on PATH |
+| `-Desp32p4-cols`, `-Desp32p4-rows` | the board's grid | 56, 14 |
-A restored terminal comes back live: its recorded output (the last MiB of it)
-is replayed as history, a dim `── restored history ──` line marks where it
-ends, and a new shell starts below it in the directory the old one was in,
-the same shell it ran (a `Tty bash` pane comes back running bash). A
-view left scrolled back stays where it was. Only the shell is new; nothing the
-old one was running is restarted. A command pane comes back finished, with
-what it printed and its `exit N`, and is not run again (a command still
-running when dumped comes back as `exit ?`). The web shell, which has no ptys, still
-shows the history alone.
+The version comes from `build.zig.zon`'s `.version`.
diff --git a/docs/detached.md b/docs/detached.md
index 46e93fd5..e010677c 100644
--- a/docs/detached.md
+++ b/docs/detached.md
@@ -1,7 +1,7 @@
# Detached sessions
-A detached session owns the editor core, pane shells, files, undo history and
-layout. TTY and SDL frontends can join and leave without ending the session.
+A detached session owns the editor core, pane shells, files, undo history
+and layout. TTY and SDL frontends join and leave without ending it.
```sh
pardes --detach=work &
@@ -9,61 +9,41 @@ pardes --attach=work
pardes-gui --attach=work
```
-Bare `--detach` names the session after its process ID. Bare `--attach`
-requires exactly one listening session. The detached process runs in the
-foreground unless the shell backgrounds it.
+Bare `--detach` names the session after its pid; bare `--attach` needs
+exactly one listening session. The detached process runs in the foreground
+unless the shell backgrounds it. Inside an editor, `Attach work` (`SPC s a`)
+switches this window to that session and `Detach` (`SPC s D`) closes only
+this frontend.
-Inside an editor, `Attach work` (`SPC s a`) switches the current window to
-that session. `Detach` (`SPC s D`) closes only that frontend. It does not
-turn a local editor into a detached session.
-
-A message stays until the next key or click dismisses it. With no frontend
-attached there is none to come, so a detached session drops each pane's
-messages once the newest has been up `MessageLinger` on the clock, checked
-by the session's own loop between the editor's steps; /screen then shows
-what is current.
+With no frontend attached, `/ctl`'s `size C R` sets the screen (160x50 until
+then; [fs.md](fs.md#the-root-ctl)), and messages clear after `MessageLinger`
+on the clock, since no key will come to dismiss them.
## Files and transport
The frontend socket is `pardes-detached-<name>.sock` under
-`$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when no runtime directory is
-set. A second session cannot replace a live listener with the same name.
-Shared Unix socket handling lives in `src/9p_io.zig`.
-
-The session also opens its default 9P socket. Optional TCP and QUIC listeners,
-runtime mounts and the control filesystem belong to the session, not its
-frontends. See [fs.md](fs.md).
+`$XDG_RUNTIME_DIR`, else `~/.local/state/pardes`, beside the session's 9P
+socket `pardes-9p-<name>.sock`. A second session cannot take a live name.
+TCP and QUIC listeners, mounts and the control filesystem belong to the
+session, not its frontends.
## Ownership
-One poll loop owns all core mutation, frontend connections, PTY I/O and file
-watch notifications. LSP and selection-pipe workers own request snapshots and
-post completions through a bounded mailbox. Each subprocess is reaped by its
-owner; PTY reaping cannot consume a language server or filter's exit status.
-
-Restore constructs a replacement core before changing the current one. It then
-joins old work, clears obsolete completions, replaces panes and watches, and
-sends a fresh frame to the existing frontends.
-
-Frontends provide input and presentation. Clipboard writes are broadcast;
-clipboard reads, browser opens and Detach go to the originating frontend,
-falling back to the primary attachment. Frontends never spawn pane shells,
-write session files or install file watches.
+One poll loop owns core mutation, frontend connections, PTY I/O and file
+watches; LSP and selection-pipe workers post completions through a bounded
+mailbox. Restore builds the replacement core before changing the current
+one, then sends existing frontends a fresh frame. Frontends provide input
+and presentation only: clipboard writes are broadcast; clipboard reads,
+browser opens and Detach go to the frontend that asked (else the first
+attached). Frontends never spawn shells, write session files or watch files.
## Wire and tests
-`src/detached/wire.zig` owns the versioned frontend protocol. Frames are full
-grids or changes relative to each frontend's last queued frame. A new
-attachment receives a full grid. Output queues and per-poll work are bounded;
-a lagging frontend cannot hold the session's event loop. Protocol version 6
-also carries the pointer shape, compact source-context body layers, source-gap
-separators and independently sized tag text. Pointer
-updates work even when no grid cells change, and graphical clients use the
-same compact row and tag geometry for drawing and mouse input. Tag text positions
-are separate from physical pane and drag-handle coordinates. Terminal clients retain
-the ordinary fixed grid.
+`src/detached/wire.zig` owns the versioned frontend protocol (version 8).
+Frames are full grids or changes against each frontend's last frame; a new
+attachment gets a full grid. Queues are bounded, so a lagging frontend
+cannot hold up the session.
-`zig build unit-test` covers encoding, session ownership, real frontend
-connections, worker completion and Restore. `zig build fs-test` drives
-detached sessions through an independent 9P client. The snapshot suites also
-exercise attach, detach and shared screen behavior.
+`zig build unit-test` covers the wire, ownership, real frontend connections
+and Restore; `zig build fs-test` drives detached sessions over 9P; the
+snapshot suites exercise attach, detach and shared screens.
diff --git a/docs/divergences.md b/docs/divergences.md
index 6679d962..2ab65819 100644
--- a/docs/divergences.md
+++ b/docs/divergences.md
@@ -1,94 +1,17 @@
# Divergences
-What is not on `main`, and what on `main` is known to be wrong. Written so
-that moving a bookmark does not quietly orphan work or hide a failure.
+What is not on `main`, and what on `main` is known to be wrong, so that
+moving a bookmark does not quietly orphan work or hide a failure.
-## Two lines, forked at `01104e7c`
+## Bookmarks off `main`
-`main` is not the only living line, and the other one is not behind it —
-they are siblings:
+| bookmark | |
+|---|---|
+| `reload-perf-wip` | unfinished: Reload presentation transport regression |
+| `reload` | reload core code with shell-owned allocators |
+| `reload-start` | names the Core/Shell seam, deferred Reload request |
+| `tty-colors-mouse`, `vibes-ghostty`, `mouse` | older prototypes (divergent), also on the `vps` remote |
-```
-◆ rruwvuzm 09-17 editor work: syntax, panes, modal, gui, fs, output
-│ ◆ xqxpolmw 2b547e15 main 09-20 "Serve Unix and TCP 9P through cloud9.serve"
-├─╯
-◆ lsnxpxtq 01104e7c 09-16 "Add macOS backdrop blur and preserve PDF ink opacity"
-```
-
-* **`main`** carries the 9P work: the `cloud9.serve` runner, and now the
- posted-9P registry (`docs/cloud9.md`).
-* **`rruwvuzm`** carries editor work — roughly 1300 lines across
- `src/syntax.zig`, `src/panes.zig`, `src/modal.zig`, `src/gui/gui.zig`,
- `src/fs.zig`, `src/pardes.zig`, `test/output.zig`, `docs/fs.md`. Reach it
- with `jj edit rruwvuzm`.
-
-The fork matters for one concrete reason: **the cloud9 pin lives on `main`
-only**. `rruwvuzm` still pins `ae310a20` (2026-09-14), `main` now pins
-`9c4d668c`. Rebasing or merging the editor line will want the newer pin, or
-`zig build` there fetches a cloud9 that predates `fs.Server`'s current shape.
-
-## Other bookmarks
-
-| bookmark | | |
-|---|---|---|
-| `reload-perf-wip` | 09-11 | unfinished: Reload presentation transport regression |
-| `reload` | 09-10 | reload core code with shell-owned allocators |
-| `reload-start` | 09-10 | names the Core/Shell seam, deferred Reload request |
-| `ninep` | 08-27 | a 9P design note and a design registry to argue it in |
-| `macos-fix` | 07-23 | |
-| `full-prototype`, `term`, `tty-colors-mouse`, `vibes-ghostty`, `mouse` | 06-xx | older prototypes, also on the `vps` remote |
-
-None of these are published to the FreeBSD mirror: only `main` is pushed
-there, deliberately, because that box serves a public site.
-
-## Known-failing on `main`, not caused by the 9P work
-
-* **`zig build snap` fails `nested-optout`, and the cause is a real bug.**
- At the SECOND level of nesting -- a pardes whose shell runs a pardes whose
- shell runs a command -- two U+E016 codepoints are prepended to whatever is
- typed. U+E016 is 57366, which is vaxis's private-use spelling of **F3**
- (`Key.zig`, "kitty encodes these keys directly in the private use area"), so
- something in the chain is decoding a capability-query REPLY as a key and
- forwarding it to the shell as text. bash then sees
- `$'\356\200\226\356\200\226echo'` and answers `command not found`; with a
- path it answers `No such file or directory` for a path that exists and runs
- perfectly from the same shell a moment later.
-
- Minimal repro, in a snapshot script:
-
- ```
- dirmk innerdir
- file innerdir/deeper.txt deepest-marker\n
- start 31 100
- wait 8000 $
- stable 700 20000
- text (cd innerdir && $(readlink /proc/$PPID/exe) --nested)
- key enter
- wait 20000 cwd/innerdir
- stable 700 20000
- text E=$(readlink /proc/$PPID/exe); "$E" deeper.txt
- key enter
- settle 5000
- stable 700 10000
- snap probe
- ```
-
- `bash: E=/home/.../pardes: No such file or directory` -- bash did not even
- parse the assignment, because the line does not start where it looks like it
- starts. `--nested` is exactly the flag meant to opt a child out of this, so
- the fix belongs next to the key-forwarding path in `panes.Terminal.forwardKey`
- and whatever answers terminal capability queries on a nested stdin.
-
-* **pardes does not start headless.** `pardes --9p=<name>` with no terminal
- exits 1 from the argument-forwarding path; the installed build fails
- earlier still, with `error.NoDevice` opening a terminal device. So the
- registry posting above is covered by unit tests
- (`zig build 9p-io-test`) rather than by running the editor.
-
-## Upstream
-
-`build.zig.zon` pins cloud9 `9c4d668c`. That commit exists because pinning
-cloud9 `534c084f` here failed: cloud9's `build.zig` `@import`s each program's
-build fragment, and `9harness` was missing from its `.paths`, so the
-published package built from a checkout and not from a tarball. The pardes
-build was the first consumer to notice.
+`ninep`, `fx`, `macos-fix`, `full-prototype`, `term`, and the `before-*` and
+`merged-*` bookmarks mark points already in `main`'s history. Only
+`main` is pushed to the FreeBSD mirror, which serves a public site.
diff --git a/docs/fs.md b/docs/fs.md
index 83906d0a..06fa1406 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -1,1086 +1,658 @@
# Filesystem
-Every native session serves 9P2000 on a Unix socket. Pane shells receive
-`PARDES_PID` (the editor's process id), `PARDES_9P` (socket path) and
-`PARDES_PANE` (pane serial). The socket is
-`$XDG_RUNTIME_DIR/pardes-9p-<pid>.sock`, or lives under
-`~/.local/state/pardes` when XDG_RUNTIME_DIR is unset. Detached sessions use
-their session name; `--9p=<name>` overrides it.
+Every native session serves 9P2000 (not .u, not .L) as a control
+filesystem, in acme's manner: panes, columns, tags and the session are files.
+This page is the reference for what each file does. The served
+[`/README`](../src/fs-help.txt) is its one-screen summary.
-A `pardes <file>` launched from a pane forwards Look to that pane over 9P.
-`PARDES_PID` alone says the shell is inside pardes; `PARDES_9P` and
-`PARDES_PANE` say how to reach it, and a launch that has the first without the
-other two refuses rather than opening a second editor. `--nested` opens a
-separate editor and withholds `PARDES_PID` from its pane shells, so a pardes
-started in one of them runs a session of its own; its 9P service stays
-available.
+## Connecting
-Look resolves the OS filesystem first, then the editor's virtual filesystem.
-Explicit paths bypass that search:
+The socket is `$XDG_RUNTIME_DIR/pardes-9p-<name>.sock` (else under
+`~/.local/state/pardes`), `<name>` being the pid, the `--detach=NAME`, or
+`--9p=<name>`. A socket in the runtime directory is also posted in the 9P
+registry as `$XDG_RUNTIME_DIR/9p/pardes/<name>` (a symlink to the socket;
+[cloud9.md](cloud9.md#the-posted-9p-registry)).
-| Editor path | Meaning | 9P server path |
-|---|---|---|
-| `/n/os/proc/self` | OS filesystem | `/os/proc/self` |
-| `/n/self/pane/2/body` | pane 2's text | `/pane/2/body` |
-| `/virtual/pane/2/body` | the same, in the editor's own spelling | `/pane/2/body` |
-| `/virtual/src/pardes.zig` | source embedded in this build | `/src/pardes.zig` |
-| `/n/peer/pane/2/body` | another session's text | peer's `/pane/2/body` |
-
-The mount name `self` is reserved and maps to the server root, so `/n/self/X`
-and `/virtual/X` both name the served `/X`.
+Pane shells get `PARDES_PID` (the editor's pid), `PARDES_9P` (the socket)
+and `PARDES_PANE` (their pane's serial).
-`--mount=peer=work` mounts the named session `work`; the dial can also be an
-absolute socket path, `unix!/path`, `tcp!IP!port`, or `quic!IP!port`.
-At runtime, use `Mount peer dial` and
-`Unmount peer`. Mount dials the peer when it mounts it and fails its write
-if nothing answers, `Mount peer /tmp/s: dial failed: no answer` (or `timed
-out`, `hung up`), mounting nothing; a peer that goes away later is found out
-by the next use, as `look: /n/peer/f: dial failed: no answer`. There are eight named mounts; `os` and `self` are reserved.
-Unmount refuses mounts still used by a pane, its working directory, or a
-pending Save. Mounts are saved in dumps. Save uses the file's original mount.
+**Forwarding.** `pardes FILE` run in a pane (a live `PARDES_PID`, with
+`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns
+at once, as acme's `B` does. FILE must already exist; a name that does not
+resolve, or a missing `PARDES_9P`/`PARDES_PANE`, starts a separate editor
+instead. Bare `pardes` in a pane refuses and names `--nested`.
-`pardes --9p-tcp='tcp!127.0.0.1!5640'` adds a TCP listener alongside the Unix
-socket. Build with `-Dquic=true` and system OpenSSL 3.6+ to enable QUIC;
-`--9p-quic='quic!127.0.0.1!5641'` adds its listener. Both accept numeric
-IPv4/IPv6 addresses, not DNS names. Listener port zero chooses a free port;
-`/listeners` reports all active dial addresses.
+`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or
+the session goes away (exit 1), as acme's `E` does. Use
+`EDITOR='pardes --wait'` (`GIT_EDITOR` follows `EDITOR`), so fish's Ctrl-O,
+`git commit` and `crontab -e` read the file after you close its pane.
+`--nested` runs a separate session whose shells do not forward to it.
-All connections have session access, including `os`. TCP is unencrypted.
-QUIC uses an ephemeral TLS identity without peer verification or login.
-It carries 9P2000 on one bidirectional stream with ALPN `pardes-9p`.
-Unix and TCP connections share sixteen slots served by cloud9's `std.Io`
-runner; QUIC has sixteen of its own on the editor's poll loop. A client
-that finds every Unix/TCP slot taken gets an Rerror `too many connections`
-to its Tversion, and the log an `err - 9p: too many connections` record. OpenSSL's
-internal buffers are separate, dynamically allocated memory.
-
-[Plan9port's client](https://9fans.github.io/plan9port/man/man1/9p.html) can
-drive Unix or TCP without a kernel mount, and `9ns` mounts the tree in a
-private namespace:
+**Clients.**
```sh
-9p -n -a "unix!$PARDES_9P" read index
-9p -n -a 'tcp!127.0.0.1!5640' ls pane/1
-9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"'
+9p -a "unix!$PARDES_9P" read index # plan9port, no mount
+9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"' # private mount
+9ns --mntgen # the whole registry at /mnt/9p
```
-(Under `9ns --unix`, `$NINE_MOUNT` is that session's root itself.)
+Under `9ns --unix` the session is `$NINE_MOUNT` itself and exists only inside
+that command. Under `9ns --mntgen` every posted session is
+`$NINE_MOUNT/pardes/<pid or NAME>/`; take the name from `$PARDES_9P`. A dead
+session's entry stays listed and answers `Input/output error`, so name the
+session rather than globbing. `Tty9p` gives one pane's shell a kernel mount
+at `$PARDES_MOUNT` ([v9fs.md](v9fs.md)).
-A `9ns --unix` mount lives in the private namespace of the command it runs,
-and nothing outside that command sees it. The mount everyone on the machine
-shares is the registry one, `9ns --mntgen` (default `/mnt/9p`): every running
-editor posts itself there, so `$NINE_MOUNT/pardes/<pid>/` is that editor's tree
-for any process, and `$NINE_MOUNT/pardes/NAME/` a `--detach=NAME` session's.
-9ns exports `$NINE_MOUNT` to everything it starts, so a script checks that
-variable to know the mount is there, and takes the name from `$PARDES_9P`
-(`pardes-9p-<pid or NAME>.sock`). A new pane made through `pane/new` is a scratch named
-`<dir>/+New` until it is given a name, where `<dir>` is the session's
-directory (the one pardes started in), whichever pane last had the keyboard:
-no pane asked for it, as acme's new window has acme's directory. So is a
-`New` written to a column's `exec` or to `/tagexec`, a word in a tag no
-pane owns. A column may hold no pane, as in acme:
-`Newcol` makes one empty, and closing a column's last pane leaves it empty
-with its tag holding the keyboard (`focus` reads empty) and logs only the
-`del`. `pane/new` places its pane as acme's makenewwindow(nil) does: in the
-active column, filling it when it is empty, else taking the bottom half of its
-last pane ([where new panes go](tags.md#where-new-panes-go)). `Delcol` closes the column. Closing the
-session's last pane quits pardes; see [tags](tags.md#empty-columns).
+plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write
+pane/3/body` replaces the whole body where acme would append. Append with
+`>>` through a mount.
-For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html),
-use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp`
-with `port=5640`. Set `uname`, `dfltuid`, and `dfltgid` for the local user.
-Leave `aname` empty. The opt-in
-[Linux v9fs experiment](v9fs.md) tests a kernel mount in a separate subprocess
-namespace (`zig build v9fs-test`, requiring explicit mount authorization).
-Neither 9P2000.u nor 9P2000.L is implemented.
-Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
+For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html)
+use `version=9p2000,cache=none,access=any`, `trans=unix` (or `trans=tcp`
+with `port=`), `uname`, `dfltuid` and `dfltgid` for the local user, and an
+empty `aname`.
-## The served tree
+**Listeners.** `--9p-tcp='tcp!127.0.0.1!5640'` adds TCP;
+`--9p-quic='quic!127.0.0.1!5641'` adds QUIC (build with `-Dquic=true`,
+OpenSSL 3.6+; ALPN `pardes-9p`, an ephemeral TLS identity, no peer
+verification). Addresses are numeric IPv4/IPv6; port 0 picks one; `/listeners`
+reads them back. Every connection has full session access, `/os` included,
+and TCP is unencrypted: use loopback. Unix and TCP share 16 connection slots;
+a 17th client's Tversion gets `too many connections` (and the log
+`err - 9p: too many connections (N turned away)`). QUIC has 16 of its own.
+Plan9port and v9fs need a userspace bridge for QUIC.
-```
-/README this guide, also src/fs-help.txt
-/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column serial
- (the name as the log shows it: one line of UTF-8, a newline in it `\n`,
- a backslash `\\`, a byte not UTF-8 `\xNN`)
-/status pid, version and pane count
-/look write a line: a right click on it at the active pane; read: the serials the last
- look, exec or ctl write touched (made, else acted at)
-/exec write a line: a middle click; read the same serials
-/log recent events, one a line: new|del|rename|save <serial> <name>, msg <serial|-> <text>,
- dump|restore <path>, err <serial|-> <file>: <why>;
- write follow to that open to wait for more
-/screen rendered screen JSON; frozen per open handle
-/listeners the session's dial addresses
-/focus the serial of the pane with the keyboard; write a serial to give it the keyboard,
- which makes its column the active one as a click there would
-/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
- `size <cols> <rows>` sets the screen of a session no frontend is attached to
- (`--detach`, 160x50 until then; refused while a frontend owns the size),
- from 20x6 to 4096x4096 (outside that, `invalid size`, EINVAL), and refused
- when a column has not the rows for its panes' minima, each its tag and 2
- rows, the minimum placement keeps; so a size once taken is taken again,
- and growing is never refused. A pane the resize took under its minimum
- gets its rows back from its column's others; a terminal's pty follows its
- pane on every resize
-/commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it:
- Edit is both, at the active pane from the root; a pane's word such as Undo or Msg
- is refused at the root), a setting's values, then ` -- ` and what it does
-/recent the files opened lately, closed ones too, most recent first, a line each:
- `open <path>` or `closed <path>` (read-only; `Recent` shows them in a pane,
- a look at a row reopening the file at its last dot; kept across sessions
- in $XDG_STATE_HOME/pardes/recent, 200 files, the oldest closed one dropped
- first, never an open one; only files on disk, not a name never saved nor
- /virtual/; a name escaped as /index's is; acme has none, its dump and
- Load the nearest)
-/layout one line per column (16 at most; the board 6; Newcol past that fails,
- `no space for a column: 16 max`, ENOSPC; and each at least 10 cells
- wide, so Newcol from a column under 20 fails `this one is too
- narrow to split`, ENOSPC), left to right: serial index x width current|notcurrent
- (the column with the keyboard now) empty|full pane-serials...; then active
- <serial>: acme's activecol, which the keyboard leaving for another column's
- tag does not move, so the two can differ -- the active column, where
- pane/new and a look place a pane next (- when there is none)
-/tag the workspace tag; > replaces it, >> appends, one line; a control character
- but a tab (a NUL too), DEL, a C1 control or bytes not UTF-8 are refused, `invalid
- tag text: ...` (EINVAL), in any tag, a pane's or a column's too
-/tagexec write a word: a middle click on it in the workspace tag; read as /exec. A
- pane's word (Undo, Msg, Save, Edit too) is refused there and at a column's exec,
- `not a session control message "Undo": write it to pane/<n>/ctl` (EINVAL),
- never done at the pane with the keyboard; a tag's own words (New, Tty,
- Find, Grep in a column's) run
-/col/<n>/tag the tag of the column with serial n, the same way
-/col/<n>/ctl write Delcol, Joincol, New or Tty: each acts on that column, as from its tag
-/col/<n>/exec write a word: a middle click on it in that column's tag; read as /exec; rmdir col/<n> closes
- an empty column (a column with panes is refused, ENOTEMPTY)
-/pane/new open it to make a pane (the bottom half of the active column's last
- pane, acme's coladd; with no room there, last all the same, taking half
- the tallest pane's rows); the read answers that pane's serial. A session holds
- 64 panes (16 on the board); at that, every route that would open one -- this
- open, look, exec, New, Tty -- fails with `no space for a pane: 64 max` (ENOSPC through 9ns, which has
- no word for ENFILE) and an err
- record, and look reads back empty; a column with no room for one
- (each pane keeps its tag and 2 rows) refuses it the same way,
- `no space for a pane in that column` (docs/tags.md)
-/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
- errors event look exec tagexec (a word as a click in its tag), plus
- pty/{ctl,status,data} on terminals
-/os/ the host filesystem
-/src/ the editor's embedded sources, only when built with -Dembed-sources=true
-```
+**Look paths and mounts.** Look resolves the OS filesystem first, then the
+editor's own tree. Explicit paths skip that search:
-`/layout`, `/tag` and `/col` go past acme, which serves no column files --
-its columns are only where a window sits. They are here so a script can see
-where the panes are (`/index`'s last word is each one's column serial) and
-edit the tags a person clicks in: a column tag is one line, a newline
-written into it a space, and a truncating write (`echo Make > col/3/tag`)
-clears it and drops the newline that ends it, as a pane tag's does. A
-column is named by its serial, as a pane is: it stays while the column
-lives, whatever opens or closes beside it, and is never reused; /layout
-gives each column's serial and its index left to right, and the log says
-`newcol <serial>` and `delcol <serial>` as columns come and go, and after a
-Restore `restoredcol <old> <new>` for each column as `restored` does for
-panes. Joincol folds a column into the one on its right, which keeps its own
-serial and tag; the joined column's panes go below that column's own, in
-their order, and its serial is gone (`delcol`). The root ctl
-takes no column word: its `Delcol` is refused, pointing at
-`col/<serial>/ctl`.
+| Look path | Meaning | Served path |
+|---|---|---|
+| `/n/os/proc/self` | the host filesystem | `/os/proc/self` |
+| `/n/self/pane/2/body`, `/virtual/pane/2/body` | this session's tree | `/pane/2/body` |
+| `/virtual/src/pardes.zig` | sources embedded with `-Dembed-sources=true` | `/src/pardes.zig` |
+| `/n/peer/pane/2/body` | a mounted session | the peer's `/pane/2/body` |
-Control messages are split by what they act on, as acme keeps window verbs
-on a window's ctl and webfs and upas/fs keep session settings on a root ctl.
-Each builtin declares its scope in src/builtins.zig (`scope = .session`;
-every setting is one, the rest act on a pane). `/ctl` takes the session's
-builtins, one a line, at whichever pane has the keyboard as each runs --
-`Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`,
-which quits the editor as acme's does (it refuses once over the panes with
-unsaved text, a `+New` scratch of 100 bytes or more too, whether it came
-through a `ctl` write or a click: first one `/log` record per pane,
-`unsaved <serial> <name>`, then the write fails with one line that is
-never a list cut short, `<name>: Modified (Exit again to discard)` for one
-pane, `4 unsaved panes: Modified (Exit again to discard)` for more, and
-the `err` record says the same. On screen the notice is short, `3 unsaved
-panes — Exit again to discard`, and the whole list stays in one `+Unsaved`
-pane, filled again by each refusal: a row a pane, `<name>: Modified` (a
-`+New` scratch with its serial, as scratches share a name, `/dir/+New
-(pane 12): Modified`), then `Exit again to discard`. Restore,
-Del and Delcol refuse the same way, with their own word; an `Exit` after more editing refuses
-again naming only the panes edited since the last refusal, as acme's does,
-and an `Exit` with nothing edited since quits, throwing all of it away; a scratch or a
-command's output under 100 bytes is not asked about, as acme's winclean
-asks about no small unnamed window (so a scratch's `dirty` of 1 in `/index`
-blocks nothing until it holds 100 bytes: it has no file to be out of step
-with, and a few lines typed to try something are not work to lose); `Restore`, which replaces every pane,
-asks the same first -- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in
-`DumpDir` (default `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`)
-and logs `dump <path>`, and `Restore` with no path takes the last one; a
-Restore puts a new editor under every client, so the write of it is
-answered and then every connection is hung up, their fids naming the old
-editor's panes (a 9ns older than cloud9 2a7137c could fail the write with
-ECONNRESET all the same, when another request's send met the hang-up
-before the answer was read; the log is the authority): dial again -- a 9ns
-mount is one such connection, so after a Restore stop it and start 9ns
-again, or every file under it fails -- and the new
-log names the restored panes and
-`restore <path>`. Restored panes have new serials (`restored <old>
-<new>` maps them) and so may columns (`restoredcol`; a fresh editor counts
-column serials from 1 again, so they often come back the same). A command
-pane comes back showing what it showed, its tag saying how it ended,
-`exit 0` (`exit ?` for one still running when dumped, whose end nobody
-saw): its command is not run again. REPL bindings are not dumped. The answer has 200 ms to leave before the cut, so a slow
-client may see only the cut; the log's `restore <path>` is what says the
-Restore happened. Keeping connections across it would mean carrying serials
-and opens into the new editor, which acme, whose Load only adds windows,
-never needed); and `Kill`, which
-does not quit but stops commands, as acme's does: bare, every command pardes
-started, and `Kill make ls`, those whose line begins with one of the words. A
-command pardes started is a command pane's, until its child exits -- Kill
-sends SIGTERM to its running job, to its shell and to every `&` job the
-line started, so the whole command stops and leaves nothing running (of
-`sleep 30; echo done` the `echo` never runs; an `&` job outlives only a
-command that exits on its own) -- or a line it typed into
-a terminal (a
-word written to `exec`, a middle click on one, a `pty/run`), from its shell's
-start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the
-"kill" note, which ends a process -- and never signals the shell itself;
-in a shell running without job control (`set +m`) the job shares the
-shell's group, so there is none to signal: Kill says `Kill: no job to
-signal`, and a write of it to `ctl` fails with that; with nothing running
-it says `Kill: nothing running`; and, of a typed line, only the
-foreground job, after which what the rest of the line does is the shell's
-affair: of `sleep 30; echo done` typed at a prompt, bash (saying
-`Terminated`) and fish (saying `Job 1, 'sleep 30' terminated by signal
-SIGTERM`) both go on and run the `echo`: SIGTERM, unlike an interrupt,
-ends only the job, not the line --
-and reads every setting there is, one a line, in the words a write of it
-takes (`Verbose on`, `WindowOpacity 70`, `PanelSlide off`, `DumpDir
-<the directory in effect>`, `LocationsConfig ...`), so writing what it reads
-back changes nothing; a setting the frontend cannot show (`Lift`,
-`GripWidth` on a terminal) is refused as `Lift is GUI-only, invalid here`
-(EINVAL: a request this build cannot take), and so is a builtin only the
-GUI has (`Fonts`), written to an `exec` too, where it would otherwise run as
-a shell command; a `Shell` or `Tty` naming no
-shell says `shell "x" not found` (ENOENT); a `DumpDir` whose last
-directory is missing has it made at the Dump, and one further up missing
-says `Dump <path>: no such directory` (ENOENT), one that is no directory
-(`/dev/null`) `Dump /dev/null/pardes-<time>.zon: /dev/null is not a
-directory`, each naming the dump file it would have written; platform and startup facts are `/status`'s and the
-Config window's, not settings. A pane's `ctl` takes the builtins that act on
-a pane (`Del`, or `Del k`/`Del j` to give its rows to the pane above or
-below, `Save f`, `Collapse`, which folds that pane, `Undo` and
-`Redo`, which step its body through its last 256 edits -- with none left
-they say `Undo: nothing to undo` and the write still succeeds, as acme's
-Undo is silent --, `Find pat`)
-beside acme's `get`, `lock` and `unlock`. The column words are pane words
-too, acting on the column that pane is in: `Delcol`, `DelAbove`, `DelBelow`
-and the focus moves `Left`/`Right`/`Up`/`Down` from it. `Joincol` and
-`Newcol` are the root's: they act on the column of the pane with the
-keyboard, and `Joincol` needs a column to its right (`Joincol: no column to
-the right`). The words are case-sensitive and do not alias: acme's verbs
-are lowercase and the builtins keep their tag spelling, so `Get` is no word
-and `del` none either.
+`--mount=peer=work` or `Mount peer <dial>` mounts a session name, an absolute
+socket path, `unix!/path`, `tcp!IP!port` or `quic!IP!port`; `Unmount peer`
+removes it. Mount dials at once and fails if nothing answers (`dial failed:
+no answer`, `timed out`, `hung up`). There are eight named mounts; `os` and
+`self` are reserved. Unmount refuses a mount a pane, a working directory or
+a pending Save still uses. Mounts are dumped.
-A write is checked whole before any line of it runs, and a line is refused
-in Plan 9's words for a ctl (kernel/misc/parse.c:82-97), quoting the line:
-`unknown control message "X"`; `wrong #args in control message "X"` for an
-argument to a builtin that takes none, or none to one that needs it (`Msg`,
-`Mount`, `Find`, a setting's value but a switch's, which flips bare);
-`bad value in control message "X"` for a setting's value it does not take
-(for `Theme`, whose names are any case, naming every theme that shares
-the name's first letter when they fit the 128 bytes a 9P error carries,
-else the nearest few, since all of them, `Themes`'s list, are too many);
-and `not a session control message "X": write it to pane/<n>/ctl` or `not
-a window control message "X": write it to /ctl` for a word of the other ctl. 9ns maps them all to EINVAL, and a write
-refused here has done nothing. A line that then fails as it runs fails the
-write with the error the editor reports for it, in its own words, which
-name what failed, the line not quoted after them (only a line refused
-before it runs, as no message at all, is quoted), e.g. `Mount: already
-mounted` (EIO), and `control message needs its
-argument "Save"` (EINVAL), for a builtin that would have asked at a prompt
-(a `Save` on a scratch) rather than open one nobody is there to answer. A
-builtin that means nothing without its argument (`Mount`, `Msg`, `Find`),
-written bare to an `exec` or `/tagexec`, is refused the same way, `wrong
-#args in control message "Mount"` (EINVAL), before it runs. The
-lines before a failing one have taken effect and those after it never run,
-which is what acme's ctl loop does (editors/acme/xfid.c:600-790). An error
-that only happens as the editor performs what a line asked for -- a `Save`
-whose disk write fails -- fails the write too, once the editor has tried
-(`Save /root/x.txt: access denied`, which 9ns reads as EACCES, a shell's
-`Permission denied`), and changes nothing: a scratch
-keeps its name and stays a scratch, a clean file stays clean, a dirty one
-dirty. Like any write, a ctl write answers once the editor has
-performed what it asked for (a save written, a shell started). A click on
-the same word, or the word written to `exec`, still opens its prompt.
+A session may open its own tree through a mount (a Look at
+`$m/pane/2/body` from the editor serving `$m`): requests are answered on the
+connection's task while the editor waits in its syscall. Through QUIC that
+still hangs.
-`/commands` lists every builtin the registry holds, in registry order, one
-a line: its word, `arg` when it takes one, `root`, `pane` or `both` for the
-ctl that takes it, for a setting that chooses among words those words,
-comma-joined, then ` -- ` and one sentence of what it does (a builtin's doc
-comment's first sentence, a setting's doc), e.g.
+## The tree
```
-Newcol root -- An empty column right of the keyboard's, its tag taking the keyboard.
-Save arg pane -- Write the pane's text to its file, or to the file its argument names.
-Verbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.
-Placement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.
+/README the one-screen guide (src/fs-help.txt)
+/index a line per pane: serial kind dirty name column
+/status pid, version, panes
+/look /exec write a line: a right / middle click at the active pane; read: the serials touched
+/log the event log; write `follow` to wait for more
+/screen the rendered screen as JSON
+/listeners dial addresses
+/focus the serial of the pane with the keyboard; write one to move it
+/ctl settings and session builtins
+/commands every builtin, one a line
+/recent files opened lately: open|closed <path>
+/layout a line per column, then `active <serial>`
+/tag /tagexec the workspace tag, and a word clicked in it
+/col/<n>/ tag ctl exec of column <n>; rmdir closes an empty one
+/pane/new open it to make a pane; read answers the serial
+/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
+ errors event look exec tagexec, and pty/{ctl,status,data,run} on terminals;
+ rmdir closes the pane
+/os/ the host filesystem
+/src/ the editor's sources (only with -Dembed-sources=true)
```
-Every builtin has its sentence; a test fails the build of one without. Such a setting written bare steps to its
-next value, so a two-valued one flips (`Crt`, `Placement`, `BootShell`), as
-its word clicked in a tag does; a value it does not take is refused with
-`bad value in control message; takes ...` naming those it does. It is
-generated from the registry, so it is always this build's own list.
+Panes and columns are named by serials the editor gives: stable while they
+live, never reused. Nothing is created by a listing, stat, walk or read:
+only an open of `/pane/new` makes a pane (so `ls -l` and `find` are safe),
+only `rmdir` of `/pane/<n>` or an empty `/col/<n>` removes. Tcreate is
+refused everywhere.
+
+`/index` rows are `serial kind dirty name column`, kind `text`, `term`,
+`pdf` or `image`, the name `<dir>/+New` for an unnamed scratch. Names may
+hold blanks, so split `head, col = row.rsplit(maxsplit=1)`, then
+`serial, kind, dirty, name = head.split(maxsplit=3)`. Names in `/index`,
+the log and a terminal's tag are escaped: a newline `\n`, a backslash `\\`, a byte that
+is not UTF-8 `\xNN`.
+
+## Rules for every file
+
+**Failure.** A write fails whenever what it asked for fails, and logs one
+`err <serial|-> <file>: <why>` record, with no `msg`. Only writes log: a
+refused open or truncation (an OTRUNC open such as `> data` after a failed
+`addr`), create or remove answers its error alone, as do a write to
+`pane/new` (`permission denied`) and a write on a read-only fid (`bad use
+of fid`). Errors are words (Plan 9's where pardes has none of its own),
+never a C library string. Through 9ns the kernel sees an errno 9ns reads
+from those words (cloud9 `9ns/src/nine.zig`, `enameToErrno`): `control
+message`, `invalid` or `bad ` is EINVAL (malformed input); `no such`, `not
+found` ENOENT; `in use` EBUSY; `no space` ENOSPC; `denied` EACCES; anything
+else, such as `no match for regexp`, `address out of range` or `Modified`,
+EIO. The `err` record has the words; a shell sees
+only the errno, most often `Invalid argument` or `Input/output error`.
+
+Not failures: a look that finds nothing (it answers nothing and logs one
+`err`), an Edit `x` that matches nothing, `Undo` with nothing to undo (a
+`msg`), and a command pane's command, which ends in its own time with an
+`exit` record.
+
+**Command lines.** `look`, `exec`, `tagexec`, the three kinds of `ctl` and a
+column's `exec` take one command a line. The whole write is checked first
+(a control character other than a tab fails it all, EINVAL); then lines run
+in order, and a failing line fails the write after the lines before it took
+effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big
+write into pieces of at most one message (msize 8192, less the header), and
+each line runs once it is whole. A write that does not fill its message is
+whole, so its last line runs even without a newline (`printf Save > exec`). An `Edit` whose `{` or
+`a`/`c`/`i` text is still open waits for the next write on that open, and
+fails at the close if it never ends (``unmatched `{'``). A line held past
+1 MiB is refused.
+
+**Answers.** Reading `look`, `exec` or `tagexec` answers the serials the
+last command made, or else the pane it acted on or focused, one a line. An
+open that wrote reads its own last answer; an open that never wrote reads
+the session's last. A read is a stream: once read, the next read on that
+fid is EOF until the next write (or seek to 0). With other clients about,
+write and read on one open:
+`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+
+**Snapshots.** `/index`, `/layout`, `/recent`, `/commands`, `/status`,
+`/listeners`, a read-only `ctl`, `/log`, `/screen` and a terminal's `body`
+freeze at the open, so one read in several chunks never splices two moments;
+open again for now. Such an open, or a `run`, `event` or `pty/data` open,
+takes one of 64 open records; past that the open fails `too many open
+files`.
+
+**Held reads.** A read with nothing to give yet (a followed `log`, `event`,
+`pty/data`, `pty/run` before its answer) waits in the editor and is answered
+when news comes. A second read on that open meanwhile fails `file in use`.
+A read the client flushed is dropped. Through a FUSE mount bash's `read -t`
+cannot time out: wrap the loop in `timeout N`.
+
+**Stats.** Lengths are real (for `event` and `pty/data` the next record's,
+zero when none waits; for `log` what an open would freeze). The qid
+version of `body`, `data` and `xdata` is the pane's revision, so a stat sees
+an edit land. Modes are 0644/0666, 0444 read-only, 0222 write-only.
+
+## look and exec
+
+A line written to `look` is a right click on it:
+
+- a path opens the file (the pane already showing it, if any); `file:12`
+ selects line 12, newline included; `file:12:5` puts the caret at line 12,
+ byte column 5; `file:<addr>` takes any address (below), **evaluated from
+ the file's dot**: `file:/re/` finds the next match after the selection,
+ `file:0/re/` the first. `:addr` addresses the pane itself.
+- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines
+ too.
+- a directory types `ls` into a terminal idle there, else opens one there.
+- a URL opens in the browser.
+- a plain word selects its next place after dot, wrapping (`LookWord list`
+ on the root ctl lists every place in a `+Search` pane instead). In a
+ terminal a word is always listed, rows spelled `@p3:12:5-9`.
+
+A miss logs `err <serial> look: ...` (`no match for "zzq:#3"` quoting what
+was written when nothing by that name exists; `<path>: no match for regexp`
+or `address out of range` when the address fails), opens nothing, and leaves
+`look` reading empty; the write succeeds.
+
+A line written to `exec` is a middle click:
+
+- a builtin word runs (`/commands` lists them): `Save`, `Del`, `New`, `Tty
+ [shell]`, `Msg text`, `Find`, `Grep`, `Edit ...`, `Mount`, ...
+ A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is
+ `wrong #args in control message "Msg"`.
+- acme's words run as pardes's where it has one (`Put` is `Save`, `Delete`
+ a `Del` that does not ask); the rest (`Get`, `Putall`, `Snarf`, `Cut`,
+ `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`) are refused, `invalid:
+ acme's Get is not a pardes builtin: ...`, never run as commands. So is a
+ GUI-only builtin (`Fonts`) on another frontend.
+- anything else is a command line, at most 1024 bytes. At a terminal idle
+ at an empty prompt it is typed into that shell. Anywhere else it runs as
+ a **command pane**: a terminal running the root ctl's `Shell` (`$SHELL`,
+ else `/bin/sh`) with `-c` and the line, in the pane's directory, with job
+ control on. Its tag reads `<dir> (<line>) running`, then `exit N`; the log
+ says `run <serial> <line>` and `exit <serial> <N|?>`; `exec` reads back its
+ serial. A typo ends `exit 127`. The directory's next command reuses a
+ finished command pane, below what it showed; one still running gets a
+ second pane. From a pane whose directory is gone nothing runs: `exec:
+ <dir>: no such directory` (ENOENT).
+
+The root's `look` and `exec` act at the active pane and log as that pane's;
+`/pane/<n>/look` and `exec` at pane n; `/tagexec` and `/col/<n>/exec`
+click in the workspace's or that column's tag, run commands in the session's
+directory, and log as `-`. A pane's word (`Undo`, `Msg`, `Save`) is refused
+at `/tagexec` and a column's exec: `not a session control message "Undo":
+write it to pane/<n>/ctl`.
+
+A background job (`&`) outlives a command that exits on its own; its output
+goes on below `exit N` until it lets go of the pty. `Kill` (root ctl)
+stops the commands pardes started: bare, all; `Kill make ls`, those whose
+line starts with one of the words. For a command pane it signals the whole
+line, `&` jobs included; for a line typed into a shell only the foreground
+job (SIGTERM), and the shell decides the rest. With nothing running it says
+`Kill: nothing running`. Kill does not reach a REPL's code: use `sig INT` on
+its `pty/ctl`.
+
+## The root ctl
+
+Reading `/ctl` gives every setting, one a line, in the words a write takes
+(`Theme orchard`, `Verbose on`, `Placement acme`, `DumpDir <dir>`,
+`Shell /bin/bash`, ...), so writing back what it reads changes nothing.
+Writes take settings and session builtins (`scope = .session` in
+`src/builtins.zig`), acting at the pane with the keyboard:
+
+- A setting written bare steps to its next value (a switch flips; so do
+ `Placement`, `BootShell`, `Crt`). A value it does not take is `bad value in
+ control message; ...` naming what it takes; `/commands` lists them. A
+ setting the frontend cannot show is refused (`Lift is GUI-only, invalid
+ here`).
+- `Newcol` makes an empty column right of the keyboard's, halving the active
+ column. `Joincol` folds the keyboard's column into the one on its right
+ (its panes go below that column's), `Joincol: no column to the right`.
+- `Exit` quits. It refuses once while panes hold unsaved text: one `unsaved
+ <serial> <name>` record per pane, then the write fails `<name>: Modified
+ (Exit again to discard)` or `4 unsaved panes: Modified (Exit again to
+ discard)` (EIO), and the list stays in a `+Unsaved` pane. The same word
+ again with nothing edited since discards; after more editing it refuses
+ again, naming only the panes edited since. `Restore`, `Del`, `Delcol` and
+ a pane's `get` refuse the same way with their own word. A `+New` scratch
+ under 100 bytes, or a command's output, is never asked about.
+- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`
+ ([config.md](config.md#dumps)) and logs `dump <path>`. `Restore [path]`
+ replaces every pane (bare: the last dump this session wrote). The Restore
+ write is answered, then **every connection is hung up**: dial again, and
+ restart a 9ns mount. The new log has a `new` per pane, `restore <path>`,
+ then `restored <old> <new>` per pane and `restoredcol <old> <new>` per
+ column. Undo history and REPL bindings are not restored; a command pane
+ comes back showing how it ended (`exit ?` if it was running) and does not
+ run again.
+- `Kill [word...]` (above), `Mount name dial`, `Unmount name`, `Theme x`,
+ `size <cols> <rows>`.
+- `size C R` sizes a `--detach` session no frontend is attached to (160x50
+ until then): from 20x6 to 4096x4096, else `invalid size`; refused while a
+ frontend owns the size, and when a column would lose its panes' minimum
+ rows (`size: too small for the panes, each its tag and 2 rows`).
+
+A write is refused whole, before anything runs, in Plan 9's words:
+`unknown control message "X"`, `wrong #args in control message "X"`,
+`bad value in control message ...`, or a word of the other ctl: `not a
+session control message "Undo": write it to pane/<n>/ctl`, `... "Delcol":
+write it to col/<serial>/ctl`, `not a window control message "X": write it
+to /ctl`. A builtin that would open a prompt (`Save` on a scratch) fails
+`control message needs its argument "Save"`. A line that fails as it runs
+fails with the editor's words (`Mount: already mounted`, `Save /root/x:
+access denied`), changing nothing.
+
+## Columns and tags
-`/focus` reads the serial of the pane with the keyboard, and a serial written
-to it gives that pane the keyboard, off any column or workspace tag that had
-it -- rio's `current` written to a window's `wctl`, named once for the whole
-tree since there is one keyboard. While a column's or the workspace's tag has
-the keyboard no pane does: `/focus` reads empty and every pane's `ctl` says
-`notcurrent`. A write gives the keyboard and nothing else: a folded pane
-stays folded (unfold it with `Collapse` on its ctl), as rio keeps `current`
-apart from `unhide`. A serial no pane has fails with `no such
-pane`; anything but a number, with `ill-formed control message`.
+`/layout` has a line per column, left to right: `serial index x width
+current|notcurrent empty|full pane-serials...`, then `active <serial>`
+(acme's activecol: where `pane/new` and a look put the next pane; `-` when
+none). `current` is the column with the keyboard now, which may differ
+from the active one. `/index`'s last field is each pane's column serial.
-A pane is made by **opening** `/pane/new`, and closed by Tremove on
-`/pane/<n>` (`rmdir`), which is the only remove the tree serves; Tcreate is
-refused everywhere, as it is in acme. Reading the open fid answers the serial
-of the pane that open made, so `n=$(cat /pane/new)` makes one and names it in
-a line. Each open makes another pane, and two reads of one fid answer the same
-serial: the open acted, the read only observes. Closing the fid leaves the
-pane.
+A session holds 16 columns (`no space for a column: 16 max`, ENOSPC), each at
+least 10 cells wide (`Newcol` from a column under 20 fails `this one is too
+narrow to split`). Since root `Newcol` halves the active column, reach 16 by
+writing `Newcol` to the widest column's `exec`.
-This is `/net/tcp/clone`'s mechanism, not acme's `new`, and the difference is
-deliberate. acme allocates during the *walk* and lets the walk land inside the
-new window, so `/dev/new/body` works in one step (acme(4): "accessing any file
-in `new` creates a new window"). acme can also afford to list `new`, because a
-Plan 9 directory read carries the stat of every entry and nothing walks. A
-kernel or FUSE mount is not so lucky: it walks and stats each name a listing
-gave it, so an allocate-on-walk name would make a pane per `ls -l`. Allocating
-on open instead keeps `new` listed and `ls` honest — a stat is not an open —
-at the cost of acme's one-step `new/body`. Nothing in the tree is created by
-list, stat, walk or read; only that one open. Every other name in `/pane` is a
-serial.
+`/col/<n>/ctl` takes `Delcol`, `Joincol`, `New` and `Tty` for that column;
+`/col/<n>/exec` is a click in its tag; `rmdir /col/<n>` closes an empty column
+(one with panes: ENOTEMPTY). A column may be empty, as in acme: `Newcol`
+makes one, and closing its last pane leaves it (the keyboard goes to its
+tag, `focus` reads empty, the log says only `del`). Closing the session's
+last pane quits pardes. The log says `newcol <serial>` and `delcol <serial>`.
-A session can open its own tree through a mount: a Look at
-`/mnt/9p/pardes/<me>/pane/2/body` from inside that very editor opens it, and
-a Save of that pane writes back through the mount into pane 2. Requests on
-the Unix and TCP listeners are answered on the 9P connection's own task, not
-by the editor's loop, so the realpath, the stat and the read the editor makes
-out through the mount come back while it waits for them. The one rule is
-whose turn it is with the core (`pardes.turn`): the editor has it, and gives
-it up while it waits for input and while a step of it is out in a syscall. A
-step of a connection task's own -- a Look written to `look` -- goes out the
-same way, and the editor waits for it to return before it takes a step of
-its own. While any step is out, a request that would change a pane (a write,
-a truncation, an rmdir) parks in the engine until none is; everything else,
-opening `pane/new` and `screen` included, is answered at once, which is why a
-Look at any path in the tree comes back. A write into the tree from the
-editor itself only ever happens between steps (a Save), so nothing it waits
-on out there is a request that has to park. QUIC is still served on the
-editor's loop, so through QUIC the old hang remains. `/n/self/...` names the
-same tree without leaving the process.
+`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag.
+A pane's starts with its computed path (an image's with its mode words, a
+PDF's with its page), then the editable text. `>` replaces the editable text
+(the default words too: `echo Make > tag` leaves only `Make`) and drops the
+one newline that ends it; `>>` appends, so `printf ' Make' >> tag` (the
+blank matters; `echo` would start a second line). A pane tag may hold
+several lines; a column or workspace tag is one, a newline written into it
+becoming a space. Control characters other than tab, DEL, C1 controls and
+non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
+most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). A clear is an ordinary edit
+and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.
-`/look` and `/exec` are the editor's two clicks, one per line of a write:
+## Panes
-- a line written to `look` is a right click on it: a path opens a file,
- `file:12` jumps to a line, a directory types `ls` into a terminal idle
- at an empty prompt there, else opens a terminal there that runs
- `ls` once (pardes has no directory listing pane of acme's kind; a shell
- in it is where one goes on from a directory), a URL opens in the
- browser, and a plain word, as acme's look3 does, selects its next
- place in that pane after the dot, wrapping at the end, opening nothing
- (`LookWord list` on the root ctl lists every place in a `+Search` pane
- instead, as pardes did before; `LookWord search` is the default). In a
- terminal, which has no dot to go on from, a plain word is always listed
- in a `+Search` pane, its rows naming the terminal as
- `@p<serial>:<line>:<cols>`, the columns a range, `@p3:12:5-9` (bytes 5
- through 9 of logical line 12, 1-based, both ends in), which a look of the
- row selects,
- and look reads that pane back.
-- a line written to `exec` is a middle click: a command word from
- `src/builtins.zig` (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`,
- `Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...;
- `Tty`'s argument is the shell it runs, `Tty fish`, and `Tty` on a pane's
- ctl opens a new terminal pane, not in that one: in the active column, as
- acme's makenewwindow puts a new window (util.c:456-467: the active column
- first, then the pane's own), under the text with room or halving the
- tallest, never shorter than its tag and 2 rows),
- or anything else, a command line. Written at a terminal idle at an EMPTY
- prompt it is typed into that shell -- any line that is no builtin, so
- `Delcol x` at a terminal goes to the shell -- (and a terminal whose shell
- exits, `exit` typed or run, closes its pane). Nothing is ever typed over
- text someone typed at a prompt and did not send. From anywhere else -- a
- file, a scratch, a tag, a terminal with a line typed at its prompt or
- whose tty a program holds -- it runs as a command pane (from a pane whose
- directory is not there it runs nothing and makes no pane, `exec: <dir>:
- no such directory`, ENOENT, as `Tty` there does; one whose shell the host
- cannot start ends at once, `exit <serial> 127` in the log, its tag no
- longer running, nothing for Kill): a
- terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) run
- with `-c` and the line, in the pane's directory, with job control on
- (bash, sh, dash, zsh, ksh `-m`; fish `status job-control full`),
- which shows its output and then `exit N` (its tag reads `<dir> (<line>)
- running`, then `exit N`), and stays. The command is over when its process
- exits, as in acme, not when its terminal closes: a job it left in the
- background prints on below `exit N` until it lets go of the pty, and a
- command that lets go of its terminal early runs on to its own exit. A
- background job outlives a command that exits on its own (Kill stops it too): job control gives it a process
- group of its own, so the hangup the kernel sends the terminal's
- foreground group when the shell exits misses it. It survives the pane
- closing too, but its writes to the terminal then fail, so start one that
- must keep writing with `nohup` or its output redirected. The directory's
- next command runs in that pane once it is done and nothing holds its pty, below what it showed, after a `% <line>` line;
- one still running gets a second pane. Before the next command the pane
- leaves any alternate screen and turns off the modes a program left on
- (mouse reports, bracketed paste, a hidden cursor); a command that clears
- the screen and its scrollback (`clear`, ED3) erases the history above it. A command pane's own exec starts
- the next command there too. The log says `run <serial> <line>` and `exit
- <serial> <N|?>`; `exec` reads back the command pane's serial; Kill stops
- its whole line, `&` jobs included; a command line is at most 1024 bytes, and a
- longer one written to an exec fails the write (EINVAL, `invalid command
- line: a command line is at most 1024 bytes`, and one with a control
- character (DEL too) but a tab, `invalid command line: it holds a control
- character (or DEL) other than a tab`; the root's exec logs either against the pane it would
- have run at) before anything in it runs; a builtin's line (a long
- `Msg`, an Edit block) may be longer. To run a command again, execute
- its line again from its directory: `echo 'make test' > pane/<n>/exec` on
- the command pane runs it there, below the last run. A misspelled word is a
- command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes
- an interactive terminal in that pane's directory.
+**Making and closing.** Opening `/pane/new` makes a scratch `<dir>/+New`
+(the session's directory), and reading that open answers its serial:
+`n=$(cat $m/pane/new)`. Each open makes another pane; two reads of one open
+answer the same serial. It goes where acme's makenewwindow puts a window
+([tags.md](tags.md#where-new-panes-go)): in the active column, filling it
+if empty, else the bottom half of its last pane. A session holds 64 panes
+(`no space for a pane: 64 max`), and every pane keeps its tag and 2 rows
+(`no space for a pane in that column: each keeps its tag and 2 rows`); both
+are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike.
+`rmdir /pane/<n>` closes the pane, unsaved or not.
-A terminal can be bound as a language's REPL: `Repl python` in its tag or
-on its `ctl` (the language names are the syntax table's, or a code fence's
-alias such as `py`, in any case; `Repl -` unbinds,
-`Repl` bare says the binding, `Repl python` again changes nothing). Its tag
-shows its id, `python-a`, `python-b` for the next, a freed letter reused,
-and so does the end of its `ctl` line, after `current`/`notcurrent`.
-A builtin's word still runs first, bound or not: `Del` clicked in the file
-closes its pane. Then any other exec made by a gesture on the body of a file
-in that language -- a
-middle click, the execute key, on a selection or a single word, even `make`
-in a comment -- or on the REPL's own body is typed into the REPL instead of
-run: bracketed paste when its program asked for it (DECSET 2004), else line
-by line, where a blank line inside a Python block ends the block (said
-once), then Enter. The message row says `→ python-a` in the tag's name tint
-and the log `send <from> <to> <id>`. With several REPLs bound for the
-language the pane asks which, on its notice band, one key answering and Esc
-sending nowhere; nothing is remembered. A REPL takes text only while its
-program has the terminal: a command pane's until its command is done (the
-binding goes with it, and a done one cannot be bound), an interactive
-terminal's while a program other than its shell holds the tty -- after
-Ctrl-D the shell would run the text, so nothing is sent and the pane says
-so. Still commands, whatever is bound: a word in a tag (so
-the tag is how to run `make` from that file), `Exec <text>` run by name
-(typed, a 2-1 chord onto `Exec`, a `ctl` line) -- in a `.py` pane bound to
-a REPL, `Exec print(1)` runs `print(1)` as a command, never in the REPL --
-and a command word @`cmd` in
-the text, looked at or clicked (`# @`pytest -x`` in a script). A 9P `exec`
-is no gesture and is never sent: a script writes to the REPL pane's
-`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~`,
-then `\r` in a write of its own once the REPL has echoed the paste --
-Python 3.13's REPL takes an Enter read with the paste as part of it, even a
-one-line one -- and, for a paste of more than one line that does not end
-in a newline, a second `\r`: one Enter leaves a multi-line input at `...`; a middle click does all of this
-itself, holding the Enter until the REPL answers the paste), since line by line a blank line ends a Python block and Python
-3.14's REPL auto-indents each line typed into it. The REPL gets the text
-wherever it is -- at a `pdb` or `input()` prompt too. Over 9P, a range of a
-`.py` pane goes to its bound REPL as a click would: write the event record
-`MX<q0> <q1>` back to the `.py` pane's `event` (a range past its end is
-refused, `range past end of body`). `Kill` does not stop
-what a REPL runs, since pardes did not start it; `sig INT` on the REPL
-pane's `pty/ctl` interrupts it as Ctrl-C would. Bindings are not dumped, so a Restore leaves none.
+**`name`** reads the file name (a terminal's directory); a write renames the
+buffer (relative to the pane's directory) and marks nothing dirty; `Save`
+then writes under the new name. A name is one line; refused (EINVAL) are a
+second line, a blank at either end, control bytes and non-UTF-8 (`bad
+character in file name: a blank at its start`, ...). Up to 255 bytes a
+component.
-The root's pair clicks at the active pane and `/pane/<n>/look` and
-`/pane/<n>/exec` at that pane; `/tagexec` and `/col/<n>/exec` click in the
-workspace's or that column's tag (never an event reader's, which hears
-only its pane's; a command they run starts in the session's directory,
-where pardes started, not the focused pane's), and what the word says is logged as the session's,
-`msg -`. Blank lines are skipped, and every other line
-is checked before any of them runs, so a control character fails the whole
-write with EINVAL; a builtin that fails there fails the write as well
-(below: one rule). Reading any of these files answers the
-serials of the panes the last command created, or, when it created none, the
-pane a look focused or the pane an exec acted on (even one it closed), one
-per line; a look that found text answers the pane the text is selected in,
-not the hits buffer it opened.
+**`body`** reads the text; a write appends; `>` (OTRUNC) replaces it all.
+A terminal's body is its history as plain text in logical lines (wrapped
+rows joined), frozen per open; writing it sends input to the child. A PDF's
+body is the text layer of the page shown; images and PDFs take no write
+(`this pane has no text`).
-Reads are a stream: once an open has read the answer, a second read on it
-gives EOF (on an open that wrote, until its next write); open it again, or
-seek to 0, to read it again. The answer belongs to the open, as
-/net/tcp/clone's does: an open that
-wrote reads what its own last write touched, from the start after each
-write, whatever offset the read comes at (a shell's `exec 3<>look` shares
-one offset between its write and its read, as with `pty/run`); an open
-that never wrote reads the session's last answer as it stood when it was
-opened. So `echo x > look; cat look` works for one client, but two
-clients doing it at once may read each other's; for that, write and read
-on one open: `exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+**`sel`** reads the selected text; a write replaces it. **`errors`** is
+write-only: text appended to the directory's `+Errors` pane (logged as
+`msg` records when no column has room).
-A write of command lines -- to `look`, `exec`, `tagexec`, a `ctl` of the
-root, a pane or a column, or a column's `exec` -- runs each line once it is
-whole: a mount cuts a big write at its message size (4 KiB through the
-kernel's, 8 KiB from a client that asks), anywhere, and each piece comes as
-a write of its own, so the open keeps a last line with no newline yet, or
-an Edit block whose text has not ended, until its next write, and runs it
-then; what is left when it closes runs at the close -- so a line written
-with no newline (`printf Save > exec`) runs only as the file closes, after
-the write has been answered, and its failure is in the log alone: end each
-line with its newline to have the write itself fail -- where an Edit block
-whose `{` or `a`/`c`/`i` text never ended fails and changes nothing
-(``unmatched `{'``, or `a, c or i text not ended by a . line`, logged as an
-`err`): sam takes the end of input for a `.`, but a block that reaches Edit
-unfinished was cut short. A fragment never runs on its
-own. A line or Edit block held past 1 MiB is refused (EINVAL).
+**`focus`** (root): a serial written gives that pane the keyboard and makes
+its column active (a folded pane stays folded); `no such pane`, or
+`ill-formed control message` for a non-number. It reads empty while a
+column or workspace tag has the keyboard.
-`@p<serial>:<address>` addresses the pane with that serial as `file:<address>`
-does a file, with any sam address (`/re/`, `?re?`, `#n`, `$`, `0/re/`, a range),
-a terminal too: its body is then the text of its logical lines (the lines
-a `+Search` of it lists), addressed from its cursor, and the match is
-selected. A miss, and a serial no pane has (with a line, `@p77:3`, as
-with any other address), each log an `err`, and look reads back empty. A look takes acme's addresses after a colon (editors/acme/look.c:450): a
-line written to `look` as `file:/re/`, `file:?re?`, `file:#n`, `file:$` or any address
-opens (or finds) the file and selects what the address names. **The
-address is evaluated from the file's dot**, as acme's is: `file:/re/`
-finds the next match after the current selection, not the first in the
-file. For the first, start at the top: `file:0/re/` (or `file:#0/re/`).
-A pattern that can match empty, such as `^`, passes over the empty match
-at #0 as sam does (a search never answers where it started), so it finds
-the next one: for the start itself write `file:0` or `file:#0`.
-`:addr` does the same in the pane itself, and a pattern may hold blanks
-(`calc.py:/return a/`). `file:12` selects line 12, its newline included,
-as acme's does; `file:12:5` puts the caret at line 12, column 5 (columns count bytes from 1,
-as `addr`'s `12:5` does and as the rows of Grep, +Search and the language
-servers write them). A bare
-`/re/` is a path, as in acme, and failing that a search for its text. A
-look that finds nothing, an address that does not evaluate, or a line past
-the file's end (`calc.py:99`) says so on the message row and in the log
-(`err <serial> look: ...`), focuses and opens nothing, keeps the
-selection, and leaves `look` reading back empty.
+**Pane ctl.** Reading it gives acme's status line: serial, tag length, body
+length, isdir (0), dirty, width in cells, font, tab width, undo available,
+redo available, then `current`/`notcurrent` and a REPL's id if bound. It
+takes:
-`/pane/<n>/name` reads the pane's file name (a terminal's directory) and
-writing it renames the buffer; a relative name resolves against the pane's
-directory. A name alone is no edit: the pane's `dirty` stays what its text made it
-(a renamed clean file is still 0, and nothing asks about it at Exit,
-Restore or Del, which ask only about text edited), and `Save` writes it
-under the new name all the same. An open's writes are one name: held
-until its newline, or its close when it has none, then applied once, however
-the writes cut it; nothing else is trimmed. Two lines are refused, EINVAL,
-in one write or as a second line on the same open (bash's `printf
-'a\nb\n' > name` writes a line at a time: the first names it). A blank inside a name is taken
-(`two words.zig`); refused, EINVAL, in acme's words (xfid.c:650) and why,
-are a blank at either end (not quietly cut off), `bad character in file
-name: a blank at its start` (or `end`), a second line, `...: a newline (a name is one
-line)`, a control byte, DEL or a C1 control (U+0080-U+009F), `...: a
-control character`, and bytes that are not UTF-8, `...: not UTF-8`. `body` appends on write and replaces on truncating open. A
-terminal's `body` is its history as plain text, frozen per open, in logical
-lines: a row the terminal wrapped is joined back to the row before it (the
-wrap is ghostty's, as `pty/run`'s output unwraps), and the last line ends
-with a newline. `sel`
-reads the selected text and writing it replaces the selection. `errors`
-appends to the directory's `+Errors` pane; with no room for that pane in
-any column, what it would have shown is logged as `msg` records instead, a
-line each, and the write still succeeds. Holding `event` open redirects the
-pane's Look and Exec clicks to that client, and so does a line written to the
-pane's own `look` or `exec`, or to the root's while that pane has the
-keyboard (never its `tagexec`, which is a click in its tag and runs), a
-click with no place in the text: an `F` record at `0 0` carrying
-the line. So a client holding `event` that wants a command run gets its own
-exec back as a record: it runs it through `ctl`, or writes the record back.
-Writing a record back performs the action, as the click would have (acme's
-xfideventwrite): a body `X` goes to a REPL bound for the text as a middle
-click does, where an `F` record, a line written to `exec`, runs as the
-command it was. acme takes back only `<origin>
-<action><q0> <q1>`, the text of that range; pardes takes the record whole as
-it was read too, and for an empty range acts on its text, which is how such
-a line is done. A click in a file's body carries the offsets of the text it
-took; one in a terminal's body cannot, since that body is a history
-snapshot, and is also at `0 0` with its text. A click that takes no text
-sends nothing, as in acme. `ctl` reads acme's window status line — serial, tag length, body
-length, a reserved zero, the dirty flag, the width in cells, the font and the
-tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`):
-whether the pane has the keyboard. It takes the pane's builtins (below),
-`get`, which reloads the buffer from the name it
-carries (unsaved edits are refused once, listed in `+Unsaved` with a short
-notice as Exit's are, the write failing `<name>: Modified (get again to
-discard)`, as acme's get asks winclean, exec.c:513). A file that changes on
-disk reloads by itself only into a buffer with no unsaved edits; one with
-them keeps its text and stays dirty, says `<name> changed on disk (get
-reloads it, Save overwrites it)` and logs `changed <serial>`, and then its
-`Save` warns once, `<name> modified on disk since read (Save again to
-overwrite)`, as acme's Put does (exec.c:577) -- acme reloads nothing by
-itself. `answer <choice>`
-for the question the pane asks on its notice band, which the log names as
-`ask <serial> <what> <choices>` -- `ask 4 del k j` for Del's side from the
-keyboard (`k` the pane above takes the rows, `j` the one below), `ask 4
-repl a b` for which bound REPL takes an exec (a REPL's letter) -- `answer -`
-taking it back as Esc does (a choice the question does not offer is refused,
-naming those it does, and the question stands; with no question standing,
-`answer` is refused),
-and acme's `lock` and `unlock` (editors/acme/xfid.c:603-611), for an
-edit of several writes to `addr` and `data` that another client must not
-land in the middle of. As in acme the lock binds only the clients that take
-it: a `lock` while another open holds it fails at once with `file in use`
-(EBUSY), to be tried again, until that open writes `unlock` or closes (or
-the pane does) -- where acme's blocks, because through a kernel or FUSE
-mount a blocked write would hold up the holder's own `unlock` and close on
-that file -- and nothing else is refused for it --
-not a write to any other file, not the person at the keyboard. It belongs to
-the open that wrote it, so only that open's `unlock` is taken; a write on an
-open that cannot write (or the editor's own, on none) cannot lock. From a
-shell the lock needs an open held across the edit, since `echo lock > ctl`
-closes, and so unlocks, at once: `exec 3>ctl; echo lock >&3; ...edits...;
-exec 3>&-`.
+- the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give
+ its rows to the pane above or below), `Save [path]`, `Collapse` (fold),
+ `Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]`
+ (a new terminal pane in its directory), and the column words acting on
+ its column: `Delcol`, `Left`/`Right`/`Up`/`Down`.
+- `get`: reload from the file (refused once over unsaved edits, `<name>:
+ Modified (get again to discard)`).
+- `lock`/`unlock` (acme's). The lock binds only clients that take it and
+ belongs to the open that wrote it: `exec 3>$pane/ctl; echo lock >&3; ...;
+ exec 3>&-`. A `lock` another open holds fails at once, `file in use`
+ (EBUSY): retry.
+- `answer <choice>` to the question the pane asks on its notice band, logged
+ `ask <serial> <what> <choices>` (`ask 4 del k j`, `ask 4 repl a b`, `ask 4
+ save path`); `answer -` takes it back.
+- acme's lowercase ctl words, done by what replaces each: `name x`, `put`
+ (Save), `clean`/`dirty`, `del`, `delete` (no asking), `dot=addr`,
+ `addr=dot`, `limit=addr`, `mark`/`nomark`, `show`, `cleartag`. `dump`,
+ `dumpdir`, `font`, `menu`, `nomenu` are refused, EINVAL. These lowercase
+ words are ctl-only: written to an `exec`, `del` is a command line.
-The three range files `addr`, `dot` and `limit` each read the pair of offsets
-they also accept, so copying one onto another is all that acme's `addr=dot`,
-`dot=addr` and `limit=addr` ever were. A write is either that pair or an
-address expression (`#0,#5`, `/pattern/`, `2+1`, and pardes's own `12:5`,
-below); `addr` selects where `data` reads from -- to the end of the text,
-as acme's does, so `cat data` reads everything after the address -- and
-the range `xdata` reads, just that; either replaces the range, `dot` is the editor's own selection and moving it
-scrolls the pane into view, and `limit` bounds only the end of a forward
-search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it --
-though a write after the truncation that fails puts the old limit back, so
-`echo /bad/ > limit` changes nothing -- and truncating `addr` leaves it as
-it is (below).
+A file changed on disk reloads by itself only when the buffer has no unsaved
+edits; otherwise it stays dirty, says `<name> changed on disk (get reloads
+it, Save overwrites it)`, logs `changed <serial>`, and its next `Save`
+warns once.
-A rename everywhere, or any other sam edit, is one write to the pane's
-`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as
-one undo step; a failure fails the write with acme's words and changes
-nothing. A write is one message a line, except that an `Edit` line takes
-the lines after it while its `{` group is open or its `a`, `c` or `i` text
-block waits for its `.` line, on a pane's `ctl`, the root's (at the active
-pane) and `exec` alike; an unclosed group is refused (``unmatched `{'``).
-A block may come in several writes on one open (bash's builtin `printf`
-writes a line at a time): the open holds it until it ends (below).
+### Addresses and data
-A line number counts newlines as sam's lineaddr does (editors/sam/
-address.c:180), so the empty line just past a text's last newline is an
-address: `1` of an empty text is `#0,#0`, `2` of `a\n` is `#2,#2`, and
-`Edit 1i/header/` on an empty file inserts; a line past that is `address
-out of range`.
+`addr`, `dot` and `limit` read the pair of byte offsets they take, so
+copying one onto another (`cp $p/addr $p/dot`) is acme's `dot=addr`. A
+write is a pair or an address expression. `addr` names where `data` reads
+(to the end of text) and the range `xdata` reads; `dot` is the selection
+(moving it scrolls the pane there); `limit` bounds the end of a forward
+search and reads empty until set. Truncating `dot` empties it, truncating
+`limit` lifts it.
-`line:col` is a pardes extension to sam's addresses, the spelling Look
-takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it
-composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is
-in bytes from 1, as Look's is, clamped to the end of the line and snapped
-back to the start of the rune it falls in; a line past the end, or
-column 0, is `address out of range`. Since the column counts bytes, a
-column a tool gives in characters (pytest's, a compiler's) is the same
-only on an ASCII line; elsewhere address the line and search it
-(`12/name/`) or use `#n`. sam would read `12:5` as a syntax
-error. The rows of Recent, a +Search and the Jumplist spell a range
-`12:5-14:2` (or `12:5-9` on one line), and `addr` takes that too, so a row
-pastes in; the two spellings side by side:
+- A write to `data` or `xdata` **replaces** the `addr` range (`>` and `>>`
+ alike); `: > data` deletes it. Truncating `data` is pardes's own (acme
+ ignores OTRUNC there); only truncating `body` empties the buffer.
+- A write leaves `addr` just past what it wrote, so a second `echo x > data`
+ inserts after the first: write `addr` before each replacement. A read of
+ `data`/`xdata` moves `addr` past what it read.
+- `addr` belongs to the pane, not the client, and neither an open nor a
+ truncation resets it (acme resets on first open): each `echo /re/ > addr`
+ searches on from the last, so a find loop advances. Write `0` to start
+ at the top; searches wrap, so stop a loop when the address comes back or
+ set `limit`.
+- A failed address leaves **no address**: `addr` reads empty and `data`/
+ `xdata` refuse (`no address: the last one written to addr failed`) until
+ a standalone address (`2`, `#0`, `/re/`) is written, so a missed target is
+ never written at the old one.
- 12:5,14:2 sam's range: from line 12 column 5 up to the point at 14:2
- 12:5-14:2 a row's range: 12:5 through the character at 14:2, inclusive
+Addresses are sam's: `#n`, a line number, `/re/`, `?re?`, `-/re/`, `$`,
+`.`, `0`, ranges `a,b` and `a;b`, `+`/`-`. They are evaluated from the
+current address (the last written to `addr`, or just past the last `data`
+write), not from the selection. pardes adds `12:5`: line 12, **byte**
+column 5 from 1, clamped to the line's end (`12:0` is `address out of range:
+a column counts from 1`); it composes (`12:5,14:1`). A tool's character
+column matches only on an ASCII line; elsewhere use `12/name/` or `#n`. A
+row of Recent, `+Search` or the Jumplist spells a range `12:5-14:2` (through
+14:2 inclusive) and `addr` takes it as such.
-A `-` right after `L:C` and a digit is this range, not sam's "back N
-lines" from that point.
-A write to `data` or `xdata` replaces the range `addr` names, as acme's
-does (editors/acme/xfid.c:491-523: it deletes the range, then inserts), so
-`echo NEW > data` and `echo NEW >> data` both replace that range, and
-truncating deletes it and nothing else, so `: > data` deletes it; only
-truncating `body` empties the whole buffer. Truncating `data` is pardes's
-own: acme ignores OTRUNC there (editors/acme/fsys.c:543). And as in acme a write
-leaves `addr` just past what it wrote, so a second `echo x > data` deletes
-the empty range there and inserts after the first rather than replacing it
-again; write `addr` before each replacement. A read of `data` or `xdata`
-moves `addr` past what it read, as acme's does.
-`addr` belongs to the pane rather than to a client and keeps what was written
-until someone writes another, so writing an address and reading it back
-evaluates it, which is what acme(4) promises of its own `addr`. Unlike acme,
-neither an open nor a truncation resets it: acme sets it to `#0` when the
-first client opens `addr` (editors/acme/xfid.c:105-108), which suits a
-client that holds the fid, but a shell opens the file anew for every
-`echo /re/ > addr` and so would search from the top each time and never
-advance. Here each such write searches on from the last address, as `>>`
-does; write `0` to start again from the top. A search wraps at the end of
-the text, so a find-all loop stops when the address comes back to where it
-began, or bounds itself with `limit`.
+Offsets and counts are bytes everywhere (acme counts runes), but every
+address lands on a rune boundary: `#n` or `L:C` inside a rune snaps to its
+start, a match covers the runes it touches, and a combining mark or a CRLF's
+`\r` is addressable alone.
-The regular expressions are mvzr's (sets, `\d`/`\w`/`\s`, `{m,n}` and
-lazy `*?` included), searched the way sam searches (editors/acme/regx.c): as
-lines, so `^` and `$` match at the start and end of any line, `.` and a
-negated class never match a newline, and `$` also matches at the end of a
-text with no final newline. A pattern that names a newline (`\n`) runs over
-the whole text instead, its `.` kept to one line; there a leading `^` still
-matches at every line start, and `$` may stand just before a `\n` (where it
-changes nothing). Any other `^` or `$` in such a pattern, `(^|\n)def` or
-`a\nb$`, is refused, EINVAL, with `bad regular expression: in a pattern with \n, ^ can only come first and $
-only just before a \n`, since mvzr would read it as the start or end of the
-whole text: never a search that silently finds nothing. An alternation
-whose every branch starts with `^` (`^def|^ `) finds a line that starts
-either way (it is taken as `^(def| )`); one that mixes anchored and
-unanchored branches (`^def|x`) is refused, `bad regular expression`, since
-mvzr keeps `^` only first. An expression is
-evaluated from the current address, the range last written to `addr` (or
-left by the last `data` write, just past it), as acme evaluates it from
-`w->addr` (xfid.c:446): `.` is that address, not the selection (`dot` is
-the selection's own file), and `#9/re/` searches from `#9`; in `/a/;/b/`
-the second search starts at the end of the first, as acme's `;` does, where
-`/a/,/b/` starts both at the current address. `/re/` searches
-forward from the end of the current range to `limit` if one is set, and
-otherwise wraps to the start of the text; `?re?` and `-/re/` find the last
-match ending before the range, wrapping to the text's last. The match is the leftmost, but of
-the alternatives at that place mvzr takes the first that matches where sam
-takes the longest (`/gam|gamma/` finds `gam`); in a search begun in the
-middle of a line, `^` inside a group can match there; and in a
-pattern that spans lines, `^`, `$` and `[^...]` keep mvzr's own meaning.
-mvzr backtracks without bound of its own (`a?` twenty times then twenty
-`a`s is 2^20 steps from each place it tries), and a search holds the editor, so pardes patches a
-step budget into mvzr's matcher (build.zig): a search that spends it,
-about 300 ms, fails with `regular expression search gave up, backtracking
-past its step budget` rather
-than answer a match it is not sure of. Ordinary patterns spend a few
-thousand steps; what runs out is exponential backtracking, and a quadratic
-pattern over a very long line (`\s*(\w+)\s*=` over 20 KB of letters).
-pardes has no regex engine of its own on purpose; these are its limits.
-Normal mode's `s` and `S` search a selection the same way (src/regexp.zig
-is the one place both call), so `^` there also means a line's start.
+Refusals: `bad address syntax`, `no match for regexp`, `address out of
+range`, `addresses out of order` (`#100,#50`), `bad regular expression`.
-An address that does not evaluate fails the write with why: `bad address
-syntax`, `no match for regexp`, `address out of range`, `bad regular
-expression`, `regular expression search gave up, ...`, or sam's
-`addresses out of order` for a range that ends before it starts
-(`#100,#50`), which acme lets through. Each end is checked first, so in a
-text shorter than 100 bytes `#100,#50` says `address out of range`. A failed write to `addr` leaves no
-address at all, where acme
-keeps the old one: until a good address is written, `addr` reads empty
-(as an unset `limit` does), and reading, writing or truncating `data` and
-`xdata` fail with `no address: the last one written to addr failed`, so a script that
-missed its target cannot then write at the last one; so does an address
-written to `addr` that goes from the current one (`.`, `+1`, `-/re/`), which
-there is none of, until an address that stands alone (`2`, `#0`, `/re/`) is.
+sam details kept: `$-1` is the last line when the text ends in a newline,
+else the one before; with a final newline the empty place after it is a line
+(`1` of an empty text is `#0,#0`, so `Edit 1i/x/` works on an empty file);
+`2,1` is an empty range at line 2's start; `/^/` finds the empty place after
+a final newline; a pattern that can match empty (`^`) passes over the match
+where the search starts.
-The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take
-`0` or `1`: whether the buffer differs from its file (a file deleted on
-disk counts, as in acme: its text is only here now, and Del, Exit and the
-rest ask first), whether a write pushes
-an undo point (writing `1` pushes one now), and whether a write scrolls the
-pane. The writes of one open of `data`, `xdata` or `body` are one undo
-step while `mark` is 1 (so `printf 'a\nb\n' > data` is one, though a
-shell writes it a line at a time), and the next open starts another. The
-pane has one history, so two opens writing at once take turns in it: each
-open's first write after the other's starts a step of its own; to
-make a loop's writes one step, write `1` (an undo point here), then `0`,
-the writes, then `1` again.
+### Regular expressions
-One rule for what fails: a write fails whenever what it asked for fails,
-whether it came to a `ctl` (the root's, a pane's, a column's, a pane's
-`pty/ctl` -- its `exec` included), `look`, `exec`, `tagexec` or a
-column's `exec`, or to any other file -- with an
-errno that fits, EINVAL for malformed input (an unknown word, a control
-character, a command line over 1024 bytes, a `size` or `winsize` out of
-range, a bad address or event record), else EIO or the errno the words
-name (ENOENT for a pane, file or directory gone -- `look .` from a pane
-whose directory is gone says `look: <dir>: no such directory`, and a
-`./zz.txt` or `../x` that is not there `look: ./zz.txt: no such file`, while a
-plain `zz.txt` is looked for as text, a miss logged as any look's -- and for a
-Find or Grep that finds nothing, `grep: pattern not found`; Grep walks
-every pane's directory on this host, passing over panes of the served
-tree (`/virtual/`, a peer's `/n/<name>/`) and directories not there,
-so none of them spoils the rest; Find, Grep and a language server's lists
-(Symbols, Diagnostics, Callers and the rest) share one `+Search` a
-directory, each run replacing what the last showed, as acme reuses a
-directory's `+Errors` (the exec reads that pane back; one that finds
-nothing empties it rather than leave the last rows), while a plain
-word's `LookWord list` search keeps a pane a pattern, ENOSPC for no room or
-slot, EBUSY for a held lock) -- and logs its reason exactly once, as `err <serial|->
-<file>: <why>`, with no `msg` for it. A builtin a click runs (Save, get's
-`Modified`, Tty with no room, Edit) is no exception. What is not a
-failure: a look that finds nothing answers nothing and logs one `err`
-(`look: no match for ...`, the same miss again counted, `(x2)`, as any
-repeated `err` is), the write succeeding; and a command line run in
-a command pane ends in its own time, told by its `exit` record.
+Patterns are [mvzr](https://github.com/mnemnion/mvzr)'s (classes, `\d\w\s`,
+`{m,n}`, lazy `*?`), searched as sam searches, line by line: `^` and `$`
+match at any line's start and end, `.` and `[^...]` never match a newline.
+The same code (`src/regexp.zig`) serves addresses, Edit, and normal mode's
+`s` and `S`. The ceiling:
+
+- The leftmost match wins, but among alternatives the first that matches,
+ not sam's longest (`/gam|gamma/` finds `gam`). mvzr keeps no
+ submatches, so Edit's `s` has no `\1`-`\9`.
+- A pattern holding `\n` runs over the whole text: there `^` may only come
+ first and `$` only just before a `\n`, else it is refused.
+- An alternation must anchor every branch or none (`^def|^ ` works,
+ `^def|x` is refused).
+- A class may hold non-ASCII runes (`[éa-z]`, a range up to 256 runes); a
+ wider range or a negated class with one (`[^é]`) is refused.
+- At most 512 operations (about 512 characters, counted after that
+ rewriting): `bad regular expression: longer than mvzr's 512 operations
+ (about 512 pattern characters)`.
+- Each search has a step budget (about 300 ms; each search of an Edit `x`
+ its own): `regular expression search took too much time, gave up`. What
+ runs out is exponential backtracking (`a?` twenty times then twenty `a`s)
+ or a quadratic pattern over a very long line.
+
+### Edit
+
+`Edit <sam commands>` on a pane's `ctl` or `exec` (or the root's, at the
+active pane) runs acme's Edit on the body: addresses as above, commands `x y
+g v c a i d s p = m t u` and `{ }`. All changes are one undo step, applied
+only if every command succeeds; a failure changes nothing and fails the write
+with acme's words (`Edit: no substitution`). An `x` that finds nothing
+succeeds silently. `p` and `=` print to the directory's `+Errors`. Not
+there: `b B D e r w f X Y`, `< | >`, `\1`-`\9`. In `s`, `&` is the match
+(`\&` a literal); in `c`, `a`, `i` it is a literal. `y` yields the stretch
+before the first match too.
+
+Braces take a command a line, so a block goes on one open, in one write or
+several:
+
+```sh
+printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl
+```
+
+### Flags and undo
+
+`dirty`, `mark` and `scroll` read and take `0` or `1`: the buffer differs
+from its file (a file deleted on disk counts); a write pushes an undo point
+(writing `1` pushes one now); a write scrolls the pane. `/index`'s dirty
+flag is `dirty`; a `+New` scratch reads 1 but holds up nothing under 100
+bytes.
+
+The writes of one open of `data`, `xdata` or `body` are one undo step (so a
+multi-line `printf` is one). To make a loop of opens one step: `echo 1 >
+mark` (a point now), `echo 0 > mark`, the writes, `echo 1 > mark`.
+
+### event
+
+Holding `event` open takes the pane's Look and Exec clicks: they come to the
+reader as records instead of acting, as do lines written to the pane's own
+`look`/`exec` (or the root's while it has the keyboard). A record is acme's
+`<origin><action><q0> <q1> <flag> <n> <text>\n`; read `n` **bytes** of
+text, which may hold newlines.
+
+- origin: `E` a 9P write to body or tag, `F` other files and the editor's
+ own lines, `K` keyboard, `M` mouse.
+- action: `X`/`L` executed/looked in the body, `x`/`l` in the tag (offsets
+ into the whole tag, path included), `I`/`D` body text inserted/deleted,
+ `i`/`d` the tag's.
+- flag: 1 the text's first word is a builtin, 4 (look) a file name or
+ address, 8 (exec) chorded: two records follow, the argument and where it
+ came from. pardes never sends flag 2.
+- A written line, or a click in a terminal's body, has no place: `0 0` with
+ its text (`FX0 0 1 6 Msg hi`).
+
+Write a record back to have it done as the click would: `<o><a><q0> <q1>\n`
+acts on that range; the whole record as read acts on its text (the only way
+for `0 0`). A chorded record with its two follow-ups, in one write or
+three, runs once with its argument. `I`, `D`, `i`, `d` are refused. A helper
+holding `event` that writes its own pane's `exec` gets its command back as a
+record: run it through `ctl` instead.
+
+### REPLs
+
+`Repl python` on a terminal's `ctl` (or in its tag) binds it as that
+language's REPL (names as a code fence spells them: `py`, `sh`, ...); its
+tag and `ctl` line show its id, `python-a`. `Repl -` unbinds, bare `Repl`
+says the binding. A middle click or the execute key on a `.py` body then
+types the text into the REPL instead of running it; builtin words, tag
+words, `Exec <text>` and @`cmd` words still run. With several bound, the
+pane asks (`ask <serial> repl a b`). Bindings are not dumped.
+
+A 9P `exec` is never sent to a REPL. A script either writes the event record
+`MX<q0> <q1>` to the `.py` pane's `event` (sent as the click would be), or
+writes the REPL's `pty/data` itself: multi-line code as a bracketed paste,
+`\e[200~<code>\e[201~`, then `\r` in a separate write once the REPL has
+echoed the paste; a paste of more than one line not ending in a newline needs
+a second `\r`. Line by line, a blank line ends a Python block and Python
+3.14 auto-indents each line.
+
+## Terminals
+
+Terminal panes also have `pty/`:
+
+- `pty/data`: write bytes as typed (`printf 'ls\r'`, `\x03` is Ctrl-C);
+ read the live output stream (a consuming queue shared by readers, not a
+ replay).
+- `pty/status`: one line, `cols rows busy`; busy is 1 while a command runs or
+ text is typed at the prompt.
+- `pty/ctl`: `winsize C R` (at least 2 rows), `sig INT|TERM|HUP|QUIT|KILL`,
+ `exec` (restart the shell in its directory: refused on a command pane, `a
+ command pane does not restart`; `exec: <dir>: no such directory` if it is
+ gone; a shell that cannot start fails and leaves the old one running).
+- `pty/run`: one line at the shell's prompt, answered on the same open:
+
+ ```sh
+ exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3; exec 3<&-
+ ```
+
+ The answer's first line is the header: `exit N` then the command's output
+ (as the screen showed it: no colour, `\r` progress collapsed, trailing
+ blanks dropped; the last 64 KiB, `exit N cut M` when M bytes were left
+ out, bare `cut` when the start scrolled away or was cleared). Or: `busy:
+ <program> is running` (bare `busy` when text is typed at the prompt),
+ `exit ?` (no status reported, not a success), `error not run` (the shell
+ refused the line, e.g. a fish syntax error), `error shell gone`, `error no
+ prompt marks`, and on a command pane `error a command runs here, not a
+ shell` (`error command done; not a shell` once it ended). A line written
+ before a fresh terminal's first prompt waits for it. One line per run;
+ a second line before the answer is refused.
+
+`pty/run` relies on the OSC 133 marks pardes injects into bash and fish;
+`exec zsh` or a continuation prompt never reports an end, so cancel the
+read. A program on the alternate screen (vim, less) leaves no output. A
+program holding the terminal (a REPL, `less`) takes no run: write to
+`pty/data`.
+
+## /log
+
+One 64 KiB ring, one record a line, kept whether or not anyone reads it. An
+open freezes it and reads to EOF. Write `follow` to that open to then wait
+for each new record (`follow new` skips the history, as `tail -n0 -f`);
+`tail -f` never writes `follow`, so it sees nothing new.
+
+```sh
+exec 3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done
+```
-`tag` reads the whole tag as the pane shows it: the computed path or PDF
-page (no mark for unsaved text: the grip shows that, and `dirty` says it),
-then the text you may edit. An image's tag begins with its mode words, not
-its path: `img petscii:off palette:commodore ascii:on <path>` by default, the modes
-it is drawn in, then the file. A write appends to that text,
-newlines included, and a tag with more than one line takes a row per line on
-screen; truncating `tag` clears it, as acme's `cleartag` does -- the default
-words (`Save Tty Collapse Del` and the rest) with it, since they are that text until
-you edit it, so `echo Make > tag` leaves only `Make` -- a truncating write
-drops the one newline that ends what it wrote, which would draw an empty
-row, and keeps any other; append with `>>` to
-keep them, with `printf ' Make' >> tag`. The leading blank is needed: the
-tag reads back with no blank after its last word, so `printf Make >> tag`
-glues `Make` onto it; and `echo ' Make' >> tag` ends with a newline, which
-starts a new line of the tag. The clearing is an edit of the tag like a typed one and its undo
-history is kept: `u` in the tag brings back the text it cleared, words
-included.
+A follower the ring outran reads `lost N` first. A Restore hangs the
+follower up: dial again and read from `restore <path>`.
-Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`,
-`name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and
-for `event` and `pty/data` the length of the record a read would
-answer, which is zero when nothing is waiting; for `log`, the text an open
-would freeze now. Modes are 0644/0666 (0444 for
-read-only files, 0222 for write-only); mtime is the pane's last edit or the
-process start. The qid version of `body`, `data` and `xdata` is the pane's
-revision, so a stat sees an edit land without reading the text; every other
-file leaves it zero rather than promise a version it cannot keep. Directory
-entries carry no sizes, and neither does `/screen`, which has no length until
-an open renders its frame; stat the entry.
+| record | when |
+|---|---|
+| `new <serial> <name>`, `del`, `rename`, `save` | a pane made, closed, renamed (a terminal's too, as its shell changes directory), saved |
+| `newcol <serial>`, `delcol <serial>` | a column made or closed |
+| `msg <serial\|-> <text>` | the editor said something (not a builtin's own name under `Verbose`) |
+| `err <serial\|-> <file>: <why>` | a write was refused or failed |
+| `run <serial> <line>`, `exit <serial> <N\|?>` | a command pane's command started and ended; also a terminal whose shell exited, before its `del` |
+| `send <from> <to> <repl-id>` | text went to a REPL |
+| `ask <serial> <what> <choices>`, `answer <serial> <choice\|->` | a pane asked, and was answered (`-`: taken back, or the pane closed) |
+| `changed <serial> [reloaded\|deleted]` | its file changed on disk (bare: under unsaved edits) |
+| `unsaved <serial> <name>` | a pane an Exit, Restore, Del or Delcol refused over, before the `err` |
+| `dump <path>`, `restore <path>` | a Dump written; a Restore, after its panes' `new`s |
+| `restored <old> <new>`, `restoredcol <old> <new>` | serial maps after a Restore |
-`/log` is one ring (64 KiB) that records whether or not anyone reads it:
-`new`, `del`, `rename` (a terminal's too, as its shell changes directory --
-a new terminal is `new N <dir>`, named by the directory it is started in,
-and renamed only when its shell goes elsewhere (the boot's, started with
-no directory given, is `new N /` until its shell says where it is); it
-runs `ls` once, at its shell's first prompt only, never later over what
-someone ran or typed first, a greeting that shows the directory it opened
-in, since a terminal is named by its directory and nothing else on it says
-where it is), `exit <serial> <N>` before
-the `del` of a terminal whose shell exited by itself, `ask <serial> <what>
-<choices>` when a pane asks a question (answered by `answer` on its ctl;
-`ask <serial> save path` when a Save made over 9P, on a terminal or a
-scratch, opens its prompt for a path, answered `answer <path>` or `answer
--`; asked again, the one open stands, not a second),
-`answer <serial> <choice|->` when it is answered, by key or ctl, `-` for
-taken back or for its pane closing with the question standing,
-`changed <serial>` when a pane's file changed on disk under its unsaved
-edits (below), `changed <serial> reloaded` when a pane with none reloaded
-it, and `changed <serial> deleted` when it was deleted or moved away, `unsaved <serial> <name>` for each pane an Exit, Restore,
-Del or Delcol refuses over (before that write's `err`), and `save <serial> <name>`,
-`dump <path>` when a Dump is written and `restore <path>` in a Restore's
-new log after its panes' `new`s (a relative Restore path is looked for in
-`DumpDir`, then in the directory pardes started in; a bare Restore takes the
-last dump this session wrote), then `restored <old> <new>` for each pane,
-mapping the serial it had to the one it has now, and `restoredcol <old>
-<new>` for each column, and `msg <serial|-> <text>`
-for every line the editor says (a builtin announcing its own name as it
-runs, with `Verbose` on, is the message row's alone, never logged: a `msg`
-is something said, `Kill: nothing running`). A builtin that
-fails its write (`ctl`, `exec`, `/tagexec` or a column's `exec`, and an
-open of `pane/new`) is logged by that write's `err` alone, no `msg`, so
-the same failure again is the same record again; a line said again word for word is counted, `msg 3 Undo: nothing to
-undo (x40)`, as `err` is (below); a text past 256 bytes is cut there,
-between words, and ends in an ellipsis, `…`, as an `err`'s reason is past
-200; its serial is the pane it ran at -- a line written to the root's
-`exec` or `look` runs at the active pane, and is logged as that pane's,
-even while the keyboard is on a column's or the workspace's tag -- and `-`
-for a line to the root's `ctl`, `/tagexec`, or a column's `ctl` or `exec`,
-which run in a tag no pane owns), and `err <serial|->
-<file>: <why>` (the serial is that of the pane whose file it is, and for
-the root's `exec` and `look` the pane the line ran at, `err 3 exec: ...`;
-`-` for another root file) for every write or truncation the tree refused or that
-failed -- through a mount a shell sees only the errno its kernel mapped the
-reply to, usually `Invalid argument`, and this is the reason (`err 3 addr:
-no match for regexp`). A record said again word for word, straight after
-itself, is counted rather than repeated (`err 3 addr: no match for regexp
-(x4)`: four in all, counting the first), so a client retrying a failing
-write does not push the rest out of the ring. A record a follower has
-already read is never rewritten: the next repeat is a line of its own
-carrying the running total, `(x5)`, and counting goes on from there, so a
-follower sees each count as a new line. Through a kernel mount a client sees only an errno, which 9ns reads from the
-error's words (cloud9's 9ns/src/nine.zig, `enameToErrno`): a malformed write
--- an unknown or ill-formed control message, `bad address syntax`, `bad
-regular expression` -- is EINVAL; a lock another open holds, EBUSY; a pane
-gone, ENOENT; a well-formed write that fails -- `no match for regexp`,
-`address out of range`, `addresses out of order`, a search that gave up,
-`<name>: Modified (Exit again to discard)` -- EIO. The err record has the
-words. There is no per-pane error file to read instead:
-acme's `errors` only takes text, and one record stream is simpler to watch
-than a file per pane. A `msg` said while a
-pane is being made can precede that pane's `new`; panes present at boot are
-recorded before anything else.
-Every EINVAL a write gets says why, in its reply and in its `err` record
-(`bad character in file name: an empty name`, `invalid write to log: it
-takes \`follow\` or \`follow new\``, `invalid write: this pane has no
-text`), never a bare `Invalid argument`.
-Control characters, DEL and C1 controls (U+0080-U+009F) in a record become
-spaces, and a byte that is not UTF-8 is written `\xNN`, so a record is one
-line of UTF-8; a pane's name, in a record, in `/index` and in a terminal's
-tag alike, shows a newline in it as `\n` rather than a space and its own
-backslash as `\\`, so a directory named with a newline and one named
-with a backslash and an `n` read back differently, as what they are.
-(An `event` record is not: acme's `<origin><action><q0> <q1> <flag> <n>
-<text>\n`, whose text may hold newlines. The origin is `E` (a 9P write to body or tag), `F` (other
-files, the editor's own lines), `K` (the keyboard) or `M` (the mouse); the
-action's case says where: `x`/`l` a click executed or looked at in the tag
-(its offsets count the tag's whole text, path included, as `tag` reads),
-`X`/`L` in the body, `I`/`D` text put in or taken out of the body, `i`/`d`
-of the tag. The flag is acme's: 1 the text's first word is a builtin's, 2
-an expansion record follows (acme's look.c:42-43, exec.c:154-157; pardes
-never sends one: a click's record already carries the word it took, its
-range and text, so 2 is never set, whatever the origin), 4 (a look) the
-text is a file name or address, 8 (an exec) chorded: two records
-follow, the argument's text and where it came from, `<file>:#q0,#q1`, each
-with no place of its own: offsets `0 0` and flag 0, `Mx0 0 0 5 hello` (as
-acme's exec.c:182 writes them).
-Written back, an `X`/`x` record executes and an `L`/`l` record looks, as
-the click would have. A chorded one (flag 8) runs with its argument: the
-record after it in the same write, else the one kept from the click; its
-two follow-up records written back after it, together or in writes of
-their own, are consumed as its, never run as commands (written back alone
-with no argument kept, it waits for its argument record); the origin letter
-written back is ignored but for `F` on `X`, which runs as the command it
-was rather than going to a bound REPL; `I`, `D`, `i` and `d` are reports and
-are refused. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and
-count pardes serves is in bytes, `#n` and `q0`/`q1` too; the event count
-follows them rather than switch alone, so an acme library reads pardes
-correctly for ASCII text and not beyond it. Offsets are bytes, but every
-address lands on a rune boundary, as sam's and acme's work in runes, never
-inside a multibyte rune and never widened to a grapheme cluster: a `#n`
-inside a rune snaps back to its start, a `line:col` likewise, a search's
-match covers the runes it touches, and a copy of addr to dot keeps its
-runes, so a lone combining mark or the `\r` of a CRLF is addressable on its
-own (an Edit's `x`, `y` and `s` match the same way: `.` is one rune); `addr` reads back the snapped offset. (How the terminal draws such a
-text, a cluster to a cell, is apart from this.) A click in a
-tag gives offsets into the whole tag as `tag` reads it, the path first.) An
-open freezes the ring's text the way `/screen` freezes a frame: reads walk it
-and end. Writing `follow` to that same open makes reads past it wait for the
-next record, one per read, after first reading all that the open froze (the
-ring's whole history, up to 64 KiB); `follow new` skips that and waits for
-what comes after, as `tail -n0 -f` does. A follower the ring outran reads
-`lost N` first.
-Closing the open is the only way back, as with rio's `consctl`. A follower
-misses nothing within a session only: a Restore hangs its connection up
-(and a 9ns mount with it, which must be restarted), so it dials again and
-reads the new log from its `restore <path>`.
+The serial is the pane the line ran at (the active pane for the root's
+`look`/`exec`), `-` for the root `ctl`, `/tagexec` and column files. A
+record said again straight after itself is counted, `err 3 addr: no match
+for regexp (x4)`; a follower sees each count as a new line. A `msg` is cut
+at 256 bytes and an `err` reason at 200, ending in `…`. Control characters
+become spaces.
-A read with nothing to give yet -- a following `log`, `event`, `pty/data`, a
-`pty/run` before its answer -- is held, the way factotum holds its log's reads
-(security/auth/factotum/log.c) and acme an event read: the core keeps it, and
-whoever next has news for it (a record, output, a run's answer, the pane
-closing, which answers `no such pane: its window shut`, ENOENT as any
-other file of a gone pane gives, where acme says "window shut down") answers it on the
-connection it came on as the turn is given up. Nothing else parked is
-retried for it. The core keeps the ticket cloud9 gave the park
-(`Conn.hold`) and answers only while that very park waits, so a read the
-client flushed or whose fid it clunked meanwhile is dropped unanswered, no
-record is spent on it, and a tag the client reuses is never answered with
-what was meant for the old one. An open waits with one read at a time, as
-acme's window keeps one `eventx`; a second read on it meanwhile fails with
-"file in use". QUIC connections still retry their parked reads each tick.
+## Other files
-`pty/run` runs one line at a terminal's prompt and answers how it ended, on
-the same open (factotum's `rpc` shape): write the line, then read `exit N`
-once the command has ended and the shell is back at a prompt, followed by
-what it printed. The output is what the screen showed between the command's
-start and end marks: stderr interleaved, `\r` progress collapsed to its last
-state, no colour, tabs as the spaces they drew, trailing spaces trimmed and
-trailing blank lines dropped (`printf 'a\n\n\n'` answers `a`), leading
-whitespace kept; a program on the alternate screen (vim, less, htop) leaves
-none, and rows a program redrew above its start are missed. A bash job notice
-printed before its PROMPT_COMMAND lands in the next run's output. Only its
-last 64 KiB are kept, from a line start, and the header then reads `exit N
-cut M` (M bytes left out). `exit N cut`, with no count, says the output's
-start is not there to read: it scrolled out of the history, the command
-erased the screen (`clear`, `watch`, a full-screen program's redraw, a
-reset -- so such a command's output may read as cut), a start or end mark
-came on the alternate
-screen, or the command printed more than 8192 rows, of which only the last
-are read so that the answer costs the editor a bounded amount. `exit ?` is a
-command whose end mark carried no status, which is not a success; `error out
-of memory` is an answer that could not be made. The header is always the
-whole first line. It reads
-`busy: <program> is running` at once when a command is running (bare `busy`
-where the host cannot name the program, and when text is typed at the prompt)
--- a run is a line typed at the shell's prompt, so a program holding the
-terminal (a REPL, `less`) takes none: write to `pty/data` for it --
-which is also when the third field of `pty/status` reads 1. `pty/status` is
-one line, three right-aligned fields and a newline: the pty's columns and
-rows, then busy (0 or 1). `pty/ctl` takes `winsize C R`, `sig
-INT|TERM|HUP|QUIT|KILL` and `exec`, which starts the pane's shell again in
-its directory: one that is gone is refused before anything runs, `exec:
-<dir>: no such directory` (ENOENT), and a shell the host cannot start (not
-there, not executable, a script whose interpreter is not there) fails the
-write with why -- `shell: shell not found`, or `shell: interpreter
-/no/such/interp not found` for a script whose `#!` names a program that is
-not there, ENOENT -- keeping the terminal and its running shell: a shell
-not there is refused before anything runs, and the host starts the new one
-before the old goes, so one that cannot start leaves the old be; a `Tty` naming such a shell or
-script is refused before anything runs (`Tty: interpreter ... not found`,
-its `err` the only record), and one whose shell cannot start fails the
-same way and leaves no pane. The host knows before it answers: the child reports a failed exec
-through a close-on-exec pipe. A directory removed
-under a running shell leaves the pane its name (never `... (deleted)`), so
-an `exec` works there once the directory is back. The size, and `pty/ctl`'s `winsize` read back, is
-what `winsize C R` last set (R of 1 is taken as 2: a one-row pty loses
-its prompt's mark and would read busy for ever), until the pane itself
-resizes and gives the pty its grid again. A line written
-before a new terminal's shell has drawn its first prompt is not busy: it
-waits for that prompt (a respawn meanwhile keeps it waiting for the new
-shell's) and is sent then, so the first command a script gives a fresh
-terminal is not lost. A shell that never draws a tagged prompt (one pardes
-could not instrument, or a startup that hangs) leaves such a line waiting
-for ever: cancel the read (interrupt it, or close the open) to give up;
-`error not run` when the shell refused
-the line without running it (a fish syntax error; the line is taken back off
-the prompt): the shell's marks say only that it did not run, so the answer
-carries no reason or code, and the shell's own complaint is in the pane's
-body (`tail body`); `exit N` and what it printed when the line ended the shell
-itself (`exit 3`, or `echo bye; exit 3`): its terminal closes, and a read
-of the run's open still answers after the pane is gone; `error shell gone`
-when the pane closed or its shell was replaced, the shell went without
-an exit status to tell, or it never started (a `Tty` in a directory that is
-not there is refused before, `Tty: <dir>: no such directory`, and makes no
-pane); `error no prompt marks` for a shell pardes could not instrument;
-`error command done; not a shell` (or `error a command runs here, not a
-shell`) on a command pane, whose child is its command.
-It relies on the OSC 133 marks pardes injects into bash and fish, tagged
-`aid=pardes` so fish's own marks and a nested shell's are ignored. A second
-line on an open whose command still runs fails the write. `exec zsh` or a
-continuation prompt never reports an end; cancel the read. A read of `run`
-waits in pardes, so through 9ns it needs 9ns's concurrent requests or it
-holds up the rest of the mount. A record
-longer than a read comes in pieces, so a shell's `read` loop works: `exec
-3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done`. bash's
-`read` takes a chunk, keeps one line and seeks back to just past it; a
-followed log, `event` and `pty/data` answer a read at an offset inside
-their last answer from that answer again, so no record is lost. Through a
-FUSE mount (9ns --mntgen) bash's `read -t` does not time out on a followed
-file: the read waits in the mount, where its timer cannot cut it; wrap the
-loop in `timeout N`, or read with `cat` in the background and poll what it
-wrote. `tail -f`
-never writes `follow`, so it sees nothing new: use the follow open instead. `/screen` returns JSON with
-`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of
-`[grapheme, style_index]`. Each open freezes one frame until close. A
-terminal `body` freezes its history on the first read of each open handle;
-a PDF's `body` reads the text layer of the page it shows (MuPDF's
-extraction; turn the page for another), and it, as an image's, takes no
-write (`this pane has no text`, EINVAL);
-`pty/data` streams live output. The listings -- `/index`, `/layout`,
-`/recent`, `/commands`, `/status`, `/listeners`, and a `ctl` opened only to
-read -- freeze at the open too, so one read in several chunks never splices
-two moments; open again for what is there now. An open that holds something between open
-and close -- a frozen screen, listing, terminal body or log, a run, an `event` or
-`pty/data` open -- takes one of 64 records (lib9p's per-fid aux, acme's
-Fid), released on close or disconnect; past that such an open fails with
-`ENFILE`. Other opens hold nothing and are not counted.
+- `/screen`: JSON `cols`, `rows`, `cursor`, `styles`, and row-major `cells`
+ of `[grapheme, style_index]`; one frame per open. Compare a cell's style
+ through `styles`, not the index.
+- `/commands`: `Word [arg] root|pane|both [values] -- sentence`, one a line,
+ generated from the builtin registry (`both`: Edit, at the active pane from
+ the root).
+- `/recent`: up to 200 files, most recent first, `open <path>` or `closed
+ <path>`; kept in `$XDG_STATE_HOME/pardes/recent`. `Recent` shows them in a
+ pane; a look at a row reopens the file at its last dot.
+- `/status`: `pid`, `version`, `panes`.
+- `/os/`: existing regular files take read, write and truncation to zero;
+ create, remove, rename and metadata changes are refused; ownership is
+ synthetic. Linux v9fs's truncation `mtime` hint is accepted and dropped.
+- `/src/` (and `/shaders` on GUI builds) with `-Dembed-sources=true`;
+ `EffectCode <effect>` lists an effect's files under `/virtual`.
-`-Dembed-sources=true` embeds the editor's sources and serves them under
-`/src` (and `/shaders` on GUI builds). `EffectCode <effect>` lists the current
-backend's implementation files under `/virtual`, which Look opens; without the
-option the command reports the sources as unavailable. It is off by default
-everywhere; the esp32p4 image in particular has no room for them (~1.8 MB of
-source against a 1.5 MiB app partition).
+## Limits
-This is a control filesystem, not a complete POSIX export. Native filenames
-may contain up to 255 bytes. Existing regular OS files support read, write,
-and truncation to zero; under `/os` protocol create, remove, rename and other
-metadata changes are refused, as is every create in the control tree and every
-remove in it but a pane directory's. Ownership and permissions under `/os` are
-synthetic.
-Zero-length truncation accepts the accompanying `mtime` hint sent by Linux
-v9fs; the hint is not stored. Standalone timestamp changes remain refused.
+| | |
+|---|---|
+| panes | 64 (16 on the board) |
+| columns | 16 (6 on the board), each at least 10 cells wide |
+| rows a pane keeps | its tag and 2 |
+| msize | 8192 offered |
+| connections | 16 Unix+TCP, 16 QUIC |
+| opens holding state | 64 |
+| named mounts | 8 |
+| command line | 1024 bytes; a held line or Edit block 1 MiB |
+| tag text | 4096 bytes |
+| regular expression | 512 operations; a step budget per search (~300 ms) |
+| undo | 256 steps |
+| log | 64 KiB ring; `msg` 256 bytes, `err` reason 200 |
+| `pty/run` output | 64 KiB |
+| file name component | 255 bytes |
-The tree lives in `src/ninep/`: `tree.zig` (nodes, lookup, readdir, dispatch,
-and the editor's reply payload over cloud9's backend contract), `pane.zig`
-(pane files), `ctl.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log
-streams), `screen.zig` and `sources.zig`. The protocol engine is cloud9's
-`fs.Server`, configured in `src/9p.zig` (the editor's and the board's
-capacities); the transports are `src/9p_io.zig` (cloud9's `serve.Runner`
-for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts);
-`src/fs.zig` keeps host access, mounts, resolution, find and grep.
+## Source and tests
-`zig build fs-test` drives real sessions using the independent Python client
-in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing
-creates nothing, that an open of `/pane/new` and a remove work, and that
-`look`, `exec`, `name`, `sel` and `log` behave. `zig build
-9p-test` checks the two engine configurations' budgets (the engine's own
-tests are cloud9's `zig build test`); `zig build fs-bench` measures
-filesystem transactions in the core.
-`zig build fs-test quic-test -Dquic=true` also exercises QUIC mounts and I/O.
+`src/ninep/`: `tree.zig` (nodes, dispatch), `pane.zig`, `ctl.zig`,
+`cols.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log), `screen.zig`,
+`sources.zig`. The engine is cloud9's `fs.Server` (`src/9p.zig`); transports
+are in `src/9p_io.zig`; `src/fs.zig` holds host access, mounts and
+resolution. `zig build fs-test` drives real sessions with `test/ninep.py`;
+`zig build 9p-test` checks the engine budgets.
diff --git a/docs/open-questions.md b/docs/open-questions.md
index d4b5699c..3f365ce9 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -1,76 +1,17 @@
# Open questions
Decisions deliberately left open, with what is known so far, so they can be
-picked up without redoing the research.
+picked up without redoing the research. None is open now.
-## Where does an unknown command word run?
+## Decided
-Raised 2026-09-27 in the review of pardes against acme (Plan 9 source at
-`~/05-genizah/principia-softwarica`).
-
-**Decided 2026-09-28: a command pane.** Clicked or written at an interactive
-terminal at its prompt, the line is typed into that shell, whose state the
-clicker can see. From anywhere else it runs as its own terminal pane whose
-child is the root ctl's `Shell` run with `-c` and the line, in the pane's
-directory: full emulation
-(colours, `less`, `vim`, `sudo`'s prompt work), the exit status from waiting
-on the child (`exit N`, `exit 127` for a misspelling, no prompt marks
-needed), Kill signalling its process group, `run`/`exit` records in the log,
-and `exec` answering its serial. One command pane per directory is reused
-once done, and keeps what it showed, as acme appends to `+Errors` and never
-clears it (util.c:213-221). Chosen over acme's process-into-`+Errors` because
-it keeps interactive programs working without a streaming runner, and over
-a PATH check before typing into a shell, which misjudges builtins, aliases
-and functions and leaves the shell's state in the way. The analysis below is
-what it was decided from.
-
-**Today.** A middle-click, an `exec` write or a tag word that is not a builtin
-is typed into a terminal pane: the pane itself when it takes a command line,
-else a shell for the pane's directory (`execute`, `ttyForDir` in
-`src/exec.zig`). The command shares that shell's cwd, environment, history
-and aliases, and its output lands in the terminal.
-
-**acme.** An external command runs as its own process: stdin from
-`/dev/null`, stdout and stderr to the directory's `+Errors` window, `$winid`
-and `%` set for it (`editors/acme/exec.c`, `run()` and its callers around
-:1180-1300 and :1425). No shell state is shared between commands.
-
-**What each costs.**
-
-* Typing into a shell: interactive programs work, and so do aliases and
- functions. But every command depends on that shell's state (is it busy,
- which directory it is in, what was typed at its prompt), and a misspelled
- builtin silently becomes a shell command. The 9P `exec` file cannot report
- that the command failed.
-* A process per command: the misspelling hazard goes away at the root, the
- editor knows each command's exit status and output, and `exec` could answer
- the way `pty/run` does. But aliases, shell functions and interactive
- programs need a terminal of their own, and output goes to a `+Errors`-style
- buffer rather than a live terminal.
-
-**Evidence from use (2026-09-28).** A fresh agent driving pardes through
-9P wrote a misspelled word to `exec`; it was typed into a terminal's fish
-shell ("Unknown command"), nothing reached `log`, and the write succeeded.
-Its report ranked this among the confusing behaviours.
-
-**Related.** `pty/run` (see `docs/fs.md`) already gives an agent the second
-behaviour inside a chosen terminal: a line in, `exit N` and its output out.
-The planned root `ctl` (session builtins) and pane `ctl` (pane builtins)
-refuse unknown words, whatever is decided here; the question is only what
-`exec` and the middle-click do with them.
-
-## Where does an exec from a code file go?
-
-**Decided 2026-09-28: to a REPL bound for its language, when one is.**
-`Repl python` binds a terminal; then an exec made by a gesture (a middle
-click, the execute key, a selection or a single word) on the body of a file
-in that language, or on the REPL's own body, is typed into the REPL
-instead of run. What stays a command whatever is bound: a builtin's word,
-a word in a tag, `Exec <text>` run by name, a command word @`cmd` in the
-text, and a 9P `exec` write (a script writes the REPL's `pty/data`). An
-event record written back is done as the click it was, REPL and all. With
-several bound the pane asks which, one key answering, and remembers
-nothing. A REPL takes text only while its program has the terminal, and a
-done command pane cannot be bound. Chosen over a per-file or per-directory
-binding, which would need a place to keep and show it; bindings are not
-dumped. `docs/fs.md` has the behaviour.
+- **Where an unknown command word runs** (2026-09-28): at a terminal idle at
+ its prompt it is typed into that shell; anywhere else it runs as a command
+ pane, a terminal whose child is `Shell -c <line>`, reporting `exit N` in
+ its tag and the log ([fs.md](fs.md#look-and-exec)). Chosen over acme's
+ process writing into `+Errors` because interactive programs keep working
+ without a streaming runner, and over checking `PATH` before typing into a
+ shell, which misjudges builtins, aliases and functions.
+- **Where an exec from a code file goes** (2026-09-28): to a REPL bound for
+ its language, when one is ([fs.md](fs.md#repls)). Chosen over a per-file
+ or per-directory binding, which would need a place to keep and show it.
diff --git a/docs/tags.md b/docs/tags.md
index 3ff5e0ef..8fe63804 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -1,312 +1,161 @@
-# Editable tags
+# Tags and columns
-Pardes has three levels of command text: the workspace tag, one tag per
-column, and each pane's tag. Column commands act on that column and run in
-its active pane (or its first pane when focus comes from another column); a
-column with no pane is described under [empty columns](#empty-columns). This makes
-`New`, `Tty`, `Find`, and `Grep` available beside the work they act on.
-A command or a `Tty` run from the workspace tag or a column tag starts in
-the session's directory, where pardes was started, as acme's row and column
-tags have no directory of their own; one run from a pane's tag or text
-starts in that pane's directory.
-`New` appears only in the column tag by default; pane tags keep their own
-save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal.
-In a terminal's own tag the word names the shell it runs, `Tty+fish`, tinted
-with the tag's name colour: `Tty+arg` is one word a tag can hold for `Tty
-arg`, so clicking it opens another terminal on that shell, as `Tty fish`
-would. Only a builtin that declares `plus_arg` reads a `+` so; `Tty` is the
-one, since other arguments (a dump's name) may contain `+`. A command pane's tag reads `<dir> (<line>) running` or `exit N`
-and offers `Kill`. `Repl python` in a terminal's tag binds it as Python's
-REPL, its id, `python-a`, beside the Tty word: a middle click or the execute
-key on a `.py` body then types the text into it rather than running it, and
-several bound ask which. `Repl` takes any language a code fence names
-(`py`, `Python`, `sh`): ada, bash, c, c_sharp, clojure, cpp, css, elixir,
-erlang, fortran, go, haskell, html, java, javascript, json, kotlin, ocaml,
-markdown, pascal, php, powershell, python, ruby, rust, scala, typst, zig;
-an unknown one is refused, naming a few of these. The tag's own words, `Exec <text>` by name and a
-command word @`cmd` still run as commands (docs/fs.md).
-A tag word runs as acme's does, in the pane's directory with no file named:
-`wc` alone waits on its stdin. pardes has no `$%` for the pane's file; name
-it (`wc notes.txt`), or select the name and middle-click `wc`, which takes a
-held selection as its argument.
-Pane and column command text leave a small gap after their aligned drag grips.
-GUI pane mode symbols are centered by their visible ink; changing the font or
-its size refreshes the cached measurements.
-The column grip has a different
-color from a pane grip. It stays blank and muted even when its column is active,
-and lights in its full accent while it is held.
-Drag it past a neighbour's middle to move the whole column there; dropped short
-of that, it moves the column's left edge instead, as a border drag would. While
-it is held, a dashed rail in the grip's accent stands where the column's left
-edge will land, beside the rule as a border drag's rail does. Only columns
-between the old and new positions shift. Each column keeps its width, panes and
-command text.
-
-Two panes in a column are resized as acme's windows are, by the grip: drag
-a pane's grip up or down its own column and its top follows, the pane above
-taking or giving the rows, either one down to its tag alone (acme's
-coldragwin keeps a window its tag line); drop it in another column and the
-pane moves there. In the GUI the 2 px rule between two panes also drags
-their seam. No row of text is a handle: a pane's last row takes a click like
-any other, and a terminal, which draws no rule, resizes by the grip. A
-column's right edge still drags its width.
-
-When Look opens the first document and its source column is too narrow for
-a new column, it splits below the originating pane, just like `Tty`, instead
-of inserting at the top of the leftmost column. The originating terminal is
-kept. As with `Tty`, a tagline-only source uses a roomier split parent.
-
-`Collapse` reduces a pane to just its tagline, giving all its body space to
-the nearest expanded pane above, or below if none is above. Other panes keep
-their heights. Execute it again to reclaim its former height from that one
-neighbor, as far as available space allows. If every pane is collapsed, the
-unused area stays blank.
-Its text and terminal process are kept;
-the command stays available in the visible tag. Every default pane tag includes
-`Collapse`, including file, terminal, image, and PDF panes.
-
-`Edit` is acme's: the rest of the line is sam's command language, run on
-the pane's body (`Edit ,x/foo/c/bar/` renames every `foo`), from a tag, a
-pane's `ctl` or `exec`. Addresses are the `addr` file's, `line:col`
-included; the commands are `x y g v c a i d s p = m t`, `u` alone, and `{ }`
-with a command per line. All its changes are one undo step, applied only
-if every command ran; an error says why in acme's words and changes
-nothing. `p` and `=` print to the directory's `+Errors`. Left out: the file
-commands `b B D e r w f X Y`, the pipes `< | >`, and `\1`-`\9` in `s`,
-since mvzr keeps no submatches; acme applies changes that come out of
-sequence with a warning, pardes refuses the Edit. In `s`, `&` in the
-replacement is the matched text (`\&` a plain `&`); in `c`, `a` and `i` an
-`&` is only an `&`, as in sam. A pattern that finds nothing says so with the
-pattern (`no match for regexp /nomatch/`), and an `s` on an empty dot says
-`no substitution: dot is empty`. A loop that finds nothing is no error, as
-in sam: `Edit ,x/zzz/c/bar/` with no `zzz` changes nothing and succeeds,
-silent, where `,s/zzz/bar/` says `no substitution`.
-
-`Undo` and `Redo` are acme's: typed or clicked in a pane's tag, or written
-to its `ctl`, they step the body back and forward through its edits, as the
-`u` and `U` keys do. They are not in the default tags.
+Pardes has three levels of command text: the workspace tag, a tag per
+column, and each pane's tag. A column tag's words act on that column and run
+in its active pane (its first pane when focus comes from another column). A
+command or `Tty` run from the workspace or a column tag starts in the
+session's directory (where pardes started), as acme's row and column tags
+have none of their own; one run from a pane's tag or text starts in that
+pane's directory. Over 9P the tags are `/tag`, `/col/<n>/tag` and
+`/pane/<n>/tag` ([fs.md](fs.md#columns-and-tags)).
-`Del` closes a pane and gives its rows to one neighbor; the rest of the
-column keeps its heights. A pane with unsaved text is refused once, as
-acme's Del warns (exec.c del, wind.c winclean): a short notice, `1 unsaved
-pane — Del again to discard`, the pane listed in a kept `+Unsaved` (`<name>:
-Modified`, then `Del again to discard`), and the same `Del` again, nothing edited since, closes it and
-throws the text away; `Delcol` refuses a column holding such a pane the same
-way (exec.c delcol). Each word warns on its own, from a key, a tag, `exec`
-or a ctl, where the refusal fails the write with EIO. `Del k` (or `DelAbove`) gives them to the nearest
-expanded pane above, `Del j` (or `DelBelow`) to the one below, each falling
-back to the other side. Bare `Del` from the keyboard (`SPC d`, or Enter on
-the word) on a pane with expanded panes both above and below asks on the
-pane's notice band: `k` or Up gives the rows above, `j` or Down below, and
-any other key keeps the pane. A click, a 9P write or a startup line never
-asks; there bare `Del` gives the rows to the nearest document above, as it
-always has. A collapsed pane is never asked about, and collapsed neighbors
-are passed over: they only keep a weight for later.
+## Default words
-A tag is a text like a pane's body: pane tags, column tags and the
-workspace tag all edit with the body's own normal and insert modes, undo
-included. A pane's tag may hold more than one line, and a line wider than
-the pane wraps onto more rows, as acme's tag does: it takes a row per shown
-line, up to eight, and leaves its body at least one row. A line exactly as
-wide as the pane stays one row, its caret at the tag's edge past the last
-character, as acme's tick is (in a terminal, on the last cell). A word the
-wrap breaks across rows is still one word to a click. A collapsed pane shows
-only the first row.
+- Workspace: `Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor
+ Debug Exit`.
+- Column: `New Tty Find Grep Joincol Delcol`.
+- File pane: `Save Tty Collapse Del`; a source file with a grammar adds
+ `TreeContext`, a result list `LocationsConfig`. PDF: `manual.pdf [1/12]
+ Tty Del PdfSections PdfTint Collapse`.
+- Terminal: `Tty+bash Save Mode Filter Collapse Del`. `Tty+bash` is one word
+ for `Tty bash`, opening another terminal on that shell (only `Tty` reads a
+ `+` so). `Mode` cycles raw terminal input, normal mode and insert mode.
+- Command pane: `<dir> (<line>) running`, then `exit N`, and `Kill`.
-A pane tag starts expanded, showing all its rows, and can be collapsed to
-one row, as acme's Tagup does. In the tag's insert mode Up on its first row
-collapses it and Down on its last row expands it; Alt-Up and Alt-Down do
-the same in either mode. acme expands or collapses on any arrow key typed in
-the tag (Up collapses, the others expand); here arrows keep moving the
-cursor, and only these keys at the tag's edges change its height. The column and workspace tags
-are one line, as acme's (cols.c:244 gives a column tag one font height): a
-newline typed, pasted or written into one becomes a space, and a dump that
-holds a column or workspace tag of several lines, from before, comes back
-with its lines joined by spaces. The path and a PDF's page at the start of a
-pane tag are computed, never stored, and read-only. The keyboard reaches
-them as the mouse does: `0` goes to the line's start, the path's, as in
-acme, and motions select and yank across the path and the commands. An edit
-that would change them is refused and leaves the cursor where it was; typing
-into a file's path instead drafts a new name, as clicking it does. When they
-grow or shrink (a rename, a PDF's page) the cursor keeps
-its place in the text after them.
+`Undo`, `Redo` and `Mode` (on files) work typed or clicked though they are
+not in the default tags. Customized tags keep their text.
-Left-click a tag or a header to type into it at the click, in insert mode.
-A pane tag wraps instead of scrolling sideways; a column or workspace tag
-reveals the caret horizontally when its text is wider than its column.
-Esc is normal mode, where everything a body's normal mode does works. There
-Tab executes the word under the cursor (or an explicit selection), and Enter
-looks it up in a pane's tag but runs it in a column or workspace tag, whose
-words are all commands; in insert mode Enter is a new line. Executing gives
-the keyboard back to the body first, so `Del`, `Kill` and `Restore` never return into a tag that is gone.
-Pasted text goes to the focused tag, not to the file or embedded shell
-beneath it.
+A tag word runs as acme's does, in the pane's directory with no file named:
+`wc` alone waits on stdin. There is no `$%`; name the file (`wc notes.txt`),
+or select the name and middle-click `wc`, which takes a held selection as its
+argument.
-`:` is the one key a tag and a body do not share: in the body's normal mode
-it focuses the pane's tag in normal mode, and in the tag's normal mode it
-goes back to the body; in a column or workspace tag it goes back to the
-active pane. Each pane, column and workspace tag remembers its own cursor
-during the session; the first `:` into a pane tag starts on its `Save`. A
-tag's text changing under it (a 9P write, a shorter tag) pulls the cursor
-back inside it.
+`Repl python` in a terminal's tag binds it as that language's REPL
+([fs.md](fs.md#repls)). `Repl` takes the languages a code fence names: ada,
+bash, c, c_sharp, clojure, cpp, css, elixir, erlang, fortran, go, haskell,
+html, java, javascript, json, kotlin, ocaml, markdown, pascal, php,
+powershell, python, ruby, rust, scala, typst, zig, and aliases such as `py`
+and `sh`.
-Moving between tags is the window keys' job, as it is between bodies
-(`Ctrl-w` or `SPC w` with `h/j/k/l`): they move to the neighbouring pane's
-body. Up from a pane with nothing above it reaches its column's tag, then
-the workspace's; Down comes back the same way, and Left and Right walk the
-column tags.
+## Pane builtins
-Search (`/`), `s`/`S`, pipe (`|`) and Save's path prompt are not typed into
-the tag: each gets a line of its own on the pane's notice band, with the
-body's insert-mode keys. Pressed in a tag, `s`, `S` and `|` answer for the
-tag's own text, as they do for a body, and a header's go on the active pane's
-band; `/` searches the body wherever it is pressed, as acme's Look from a tag
-does.
+`Collapse` folds a pane to its tagline, giving its rows to the nearest
+expanded pane above (else below); again, it takes them back. Its text and
+process are kept.
-Pane filenames and commands now have a single separator space rather than
-generated right-alignment padding. Intentionally customized spacing is kept.
+`Del` closes a pane, giving its rows to one neighbour. `Del k` (`DelAbove`)
+gives them to the nearest expanded pane above, `Del j` (`DelBelow`) below,
+each falling back to the other side. Bare `Del` from the keyboard (`SPC d`),
+with expanded panes above and below, asks on the notice band (`k`/Up above,
+`j`/Down below, any other key keeps the pane); a click, a 9P write or an
+`init` line never asks and gives the rows above.
-## File names
+A pane with unsaved text is refused once: a notice `1 unsaved pane — Del
+again to discard`, the pane listed in `+Unsaved`, and over 9P the write
+fails (EIO). The same `Del` again, nothing edited since, discards. `Delcol`
+refuses a column holding such a pane the same way, without opening
+`+Unsaved` or moving focus. `Exit` and `Restore` do the same over the whole
+session. A `+New` scratch under 100 bytes is never asked about.
-Clicking a file pane's name, or typing into it from the tag, starts a draft
-of it, typed into where it was clicked or typed.
-Enter or Tab confirms the new buffer name; Escape or leaving the pane
-cancels the draft. Confirmation changes the
-buffer's save target and marks it unsaved. It does **not** rename, create or
-overwrite a disk file. A subsequent explicit `Save` writes the buffer to its
-committed name. Executing a pane-tag command confirms a valid name draft
-first, so a visible draft cannot silently save to the previous name.
+`Edit` runs sam's command language on the body ([fs.md](fs.md#edit)).
+`Undo` and `Redo` step the body through its last 256 edits, as `u` and `U`.
-Terminal working directories and generated image/PDF status remain managed
-by their corresponding commands. Their command tails are editable just like
-file command tails.
+Unsaved text shows on the pane's grip, as acme's modbutton, not in the tag.
+In a terminal the grip is two cells: the pane's mode (blank normal, `^`
+insert, `$` tty mode), then `*` while unsaved.
-PDF tags follow the same filename-first layout: `manual.pdf [1/12] Tty Del
-PdfSections PdfTint Collapse`. Sections and tint commands remain in the editable
-tail, without displaying the current tint state. `PdfFit` (`SPC t z`) remains
-available, as do the shortcuts for `PdfTint` (`SPC t i`) and `PdfSections`
-(`SPC t s`, or `f` on a PDF).
+## Editing tags
-Terminal tags include `Mode`, which cycles through raw terminal input, normal
-editor mode, insert mode, and back to terminal input. On files and text output
-panes, `Mode` cycles between normal and insert mode; it is available as a command
-but does not appear in their default tags. Images and PDFs keep their normal mode.
-Executing `Mode` from a tag leaves the tag and advances the parked body mode.
+A tag is text like a body, with the body's normal and insert modes and undo.
+A pane tag may hold several lines and wraps, taking a row per shown line up
+to eight and leaving its body at least one; a collapsed pane shows the first.
+Up on its first row in insert mode (or Alt-Up in either mode) folds a tag to
+one row, Down on its last (Alt-Down) unfolds it. Column and workspace tags are
+one line: a newline typed, pasted or written into one becomes a space.
-Ctrl-B keeps its terminal/editor toggle. Existing custom tags can still use
-`Togglettymode` for that two-way terminal toggle. Old default terminal tags
-upgrade to `Mode`; customized command text is preserved.
+The path (and a PDF's page) at the start of a pane tag is computed and
+read-only; `0` goes to its start, and motions select and yank across it.
+Typing into a file's path, or clicking it, starts a draft of a new name:
+Enter or Tab confirms it, Escape or leaving the pane cancels. Confirming
+changes the buffer's save target and marks it unsaved; nothing on disk is
+renamed until `Save`. A pane-tag command confirms a valid draft first.
-## Saved workspaces
+Left-click a tag to type at the click, in insert mode. Esc is normal mode:
+there Tab executes the word under the cursor (or the selection); Enter looks
+it up in a pane's tag and runs it in a column or workspace tag. Executing
+gives the keyboard back to the body first. Paste goes to the focused tag.
-`Dump` and `Restore` preserve customized workspace and column tags, including
-intentionally empty tags, and columns that hold no pane. New columns start with the standard column tag.
-Closing a column keeps surviving columns' tags; `Joincol` keeps the destination
-column's tag. Old dumps without these optional fields retain the defaults.
-The automatic `Restore` shortcut does not overwrite a customized workspace
-tag.
+`:` in a body's normal mode focuses its tag (the first time, on `Save`); `:`
+in a tag goes back. The window keys (`Ctrl-w`, `SPC w` with `h/j/k/l`) move
+between panes; up from the top pane reaches its column's tag, then the
+workspace's, and Left/Right walk the column tags. Search, `s`/`S`, pipe and
+Save's path prompt get a line on the pane's notice band; in a tag `s`, `S`
+and `|` act on the tag's own text, while `/` searches the body.
-The column row stays above panes with `TagBottom` enabled. On screens shorter
-than three rows it is omitted so a pane still has room. SDL and TTY share the
-same tag text, editing and layout; SDL additionally uses compact font sizing
-and subtle pixel separators.
+## Moving and resizing
-The column row is always shown. An old `ColumnTags` line in an init file is
-ignored, with a message saying so.
-`FocusTint` controls active-column and active-pane emphasis. SDL also honors
-the shared bold, underline, and strikethrough attributes, including diagnostic
-underlines and the optional `SyntaxBold` keyword weight.
+Drag a pane's grip up or down its column and its top follows, the pane
+above giving or taking rows, down to its tag alone; drop it in another
+column and it moves there. In the GUI the 2 px rule between panes drags too.
+A column's right edge drags its width. Drag a column's grip past a
+neighbour's middle to move the whole column there; short of that it moves
+the column's left edge.
-Unsaved text shows on the pane's grip button, as acme's modbutton does,
-not in the tag: the tag names the file and nothing more. In a terminal the
-grip is two cells: the second is `*` while the pane holds unsaved text, and
-the first is the pane's mode: blank in normal mode, `^` in insert, `$` when
-the keyboard goes to a terminal's program (tty mode). The `dirty` file
-and `index`'s flag say the same to a script.
+A terminal keeps its tag and 2 body rows: no drag, squeeze or smaller
+window takes it below that, and `pty/ctl`'s `winsize` gives a pty 2 rows at
+least. A text pane can be dragged down to its tag.
## Empty columns
A column can hold no pane, as acme's can: its tag stands over blank space in
-the theme's `empty_col` colour, white in the acme theme as acme paints it
-(cols.c:186-188), and the frame's border fill in themes that do not set it. `Newcol` makes an
-empty column right of the keyboard's and gives its tag the keyboard. Closing a
-column's last pane (`Del`, `Del k`/`Del j`, a shell exiting, a drag to another
-column) leaves the column empty where it was, and the keyboard goes to its tag
-if it was on that pane. Only `Delcol` and `Joincol` take a column away.
-
-`Delcol` and `Joincol` from a column's tag act on that column; `Delcol`
-written to a pane's ctl closes that pane's column. A pane dragged onto an
-empty column fills it.
+the theme's `empty_col` colour. `Newcol` makes one right of the keyboard's
+and gives its tag the keyboard. Closing a column's last pane leaves the
+column empty, the keyboard on its tag. Only `Delcol` and `Joincol` take a
+column away; `Joincol` keeps the right column's tag, its panes below. A pane
+dragged onto an empty column fills it. `Delcol` of the last column leaves
+the workspace tag alone; `Newcol` or `New` starts again. Unlike acme, pardes
+quits when the session's last pane closes.
## Where new panes go
-Every new pane goes through one placement, chosen by the `Placement` setting:
-`acme` (the default) or `pardes`. `Placement pardes` or `Placement acme` sets
-it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c
-p` is its leader path, and `Config` reports it.
+Every new pane goes through one placement, chosen by `Placement acme` (the
+default) or `Placement pardes` (bare flips it; `SPC c p`).
-Under either, no placement leaves a pane, new or split, shorter than its tag
-and two body rows (acme's minht keeps one). Where the place chosen has not
-that room, the column's tallest pane is halved instead; where no one pane
-can give it but the column's rows hold every pane's tag and two rows with
-the new one's, the rows are shared out again, each at least its minimum;
-only when they do not -- the arithmetic, not the halving, decides -- is
-the new pane refused and closed, with `no space for a
-pane in that column: each keeps its tag and 2 rows` (a 9P write or open of
-`pane/new` fails with it, ENOSPC). A pane alone in its column always fits.
+No placement leaves a pane shorter than its tag and 2 body rows. Where the
+chosen place has not that room, the column's tallest pane is halved; where
+no one pane can give it but the column holds every pane's minimum with the
+new one's, the rows are shared out again; otherwise the pane is refused,
+`no space for a pane in that column: each keeps its tag and 2 rows` (ENOSPC
+over 9P). A pane alone in its column always fits.
-After placement a terminal keeps that floor too: no drag of a grip, no
-squeeze of its column by the others' weights and no smaller window takes
-it below its tag and two body rows (a one-row terminal loses its prompt's
-mark and would read busy for ever); its neighbours give the rows, and only
-a window too short for every floor leaves it less. A text pane keeps
-acme's way and can be dragged down to its tag alone; acme has no
-terminals to follow here. A folded terminal (Collapse) is a tag by choice.
-`pty/ctl`'s `winsize` likewise gives a pty two rows at least.
-A `+Errors` pane goes to any column with room, the last first; with none,
-what it would have shown (an Edit's `p` or `=`, a write to `errors`) is
-logged as `msg` records, a line each, and the Edit still succeeds.
+`Placement acme` is acme's makenewwindow. The active column is the one last
+typed or clicked in, dropped into, whose tag was given the keyboard, or that
+was given the last new pane; a Look moves the keyboard, not the active
+column. A new pane goes into the column whose tag the command came from,
+else the active column, never into a new column:
-`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps
-acme's *active column* (activecol, dat.c:37): the column last typed in
-(acme.c:487), clicked in with the select button (acme.c:659), dropped into by
-a grip (acme.c:640), whose tag was given the keyboard (`Newcol`, an emptied
-column, `Ctrl-w k`), or that was given the last new pane (util.c:467). A Look
-click moves the keyboard but not the active column, as button 3 does not in
-acme. A new pane goes into the column a command's tag belongs to when it came
-from a column tag, else the active column, else the keyboard's pane's, and
-never into a new column:
+- an empty column it takes whole;
+- from a tag, or 9P's `pane/new`, it takes the bottom half of the column's
+ last pane;
+- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it
+ goes under the text of the pane with the most blank rows when that is
+ more than 15 rows, or more than 3 and more than half the biggest pane;
+ otherwise it halves the biggest pane, or the asking pane when that is in
+ the column and not much smaller;
+- `New` goes to the bottom half of its own column's last pane;
+- a command pane or `+Errors` pane goes to the last column's last pane (a
+ command from a column's tag to that column, reusing a finished command
+ pane only there). With no room anywhere, `+Errors` text is logged as `msg`
+ records.
-- an empty column it takes whole (util.c:468-469);
-- from a tag, or 9P's `pane/new` (acme's `t->w == nil`), it takes the bottom
- half of the column's last pane (coladd, cols.c:62-65);
-- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it goes
- right under the text of the pane with the most blank rows when that is more
- than 15 rows, or more than 3 and more than half the biggest pane
- (util.c:482-486); otherwise it halves the biggest pane, or the asking pane
- when that is in the column and not much smaller (util.c:487-491);
-- `New` goes into its own column, the bottom half of its last pane
- (look.c:921-923);
-- a command pane or a `+Errors` pane goes to the last column, the bottom
- half of its last pane (util.c:94-98); a command run from a column's tag
- goes to that column instead, and reuses a finished command pane only in
- that column.
+`Placement pardes`: an empty column whose tag asked, or has the keyboard, is
+filled; a scratch goes under the pane that asked; a shell under it or the
+nearest pane with room; a document beside the last one read, or in a column
+of its own on the left when there is none and the column is at least 200
+cells wide; a command pane at the foot of the last column.
-`Placement pardes` is what pardes did before: an empty column whose tag asked,
-or has the keyboard, is filled; a scratch goes right under the pane that asked;
-a shell under it or the nearest pane with room; a document beside the last one
-read, or in a column of its own on the left when there is none and the column
-is at least 200 cells wide; a command pane at the foot of the last column.
-
-`BootShell replace` brings back one more placeholder: a document dragged into
-the left column closes the column's lone shell if nobody has typed into it.
-`BootShell keep`, the default, never closes a pane for another one.
-
-Down from an empty column's tag stays there, and the pane-to-pane keys pass
-over an empty column; Left and Right from a tag walk every column's tag.
+## Saved workspaces
-`Delcol` of the last column does as acme's does: the column goes and the
-window stays, empty but for the workspace tag, where `Newcol` (or `New`,
-which makes the column it goes in) starts it again. One divergence from
-acme remains: acme keeps running when its last window closes; pardes quits
-when the session's last pane closes by `Del`, a shell exiting or the like.
+`Dump` and `Restore` keep workspace and column tags (empty ones too) and
+empty columns ([config.md](config.md#dumps)). The column row stays above
+the panes with `Tagbottom` on; on screens under three rows it is left out.
diff --git a/docs/v9fs.md b/docs/v9fs.md
index a105fe06..26460658 100644
--- a/docs/v9fs.md
+++ b/docs/v9fs.md
@@ -1,114 +1,56 @@
-# Linux terminals with a kernel 9P mount
+# Tty9p: a terminal with a kernel 9P mount
-Execute `Tty9p`, or press `SPC n 9` in editor mode, to open a terminal below
-this pane with the current Pardes session mounted through Linux v9fs.
-
-The new pane asks for your sudo password when needed. After mounting, it starts
-your configured shell as your normal user, with your account's supplementary
-groups. The shell receives `PARDES_MOUNT`, the absolute mountpoint:
+On Linux, `Tty9p` (`SPC n 9`) opens a terminal below this pane with the
+session mounted through the kernel's v9fs. The pane asks for your sudo
+password, mounts, and starts your shell as your normal user (with your
+supplementary groups). The shell gets `PARDES_MOUNT`, the mountpoint:
```sh
-ls "$PARDES_MOUNT/pane"
cat "$PARDES_MOUNT/index"
-cat "$PARDES_MOUNT/README"
cat "$PARDES_MOUNT/pane/$PARDES_PANE/body"
echo 'Msg hello' > "$PARDES_MOUNT/exec"
n=$(cat "$PARDES_MOUNT/pane/new")
```
-**Opening** `pane/new` makes a pane, and reading that open file answers its
-serial; address the pane as `pane/<serial>` from then on. Each open makes
-another one, so read it once and keep the number. A *stat* makes nothing,
-which is why `new` can be listed at all: `ls`, `ls -l` and `find` over the
-whole mount create nothing, because none of them open it.
-
-The mount belongs to that pane's subprocess tree. Other panes and the editor
-core keep their original mount namespace. It works in native Linux TTY and SDL
-sessions, including detached sessions. A frontend attaching from elsewhere does
-not perform the mount; the session host starts the new terminal.
-
-## Build and setup
-
-The normal Linux build installs the ordinary `pardes-v9fs` executable beside
-`pardes` and `pardes-gui`:
-
-```sh
-zig build
-```
+The files are those of [fs.md](fs.md). The mount lives in that pane's
+private mount namespace: other panes and the editor do not see it, so a
+Look at a path under `$PARDES_MOUNT` opens nothing. Each `Tty9p` makes its
+own mount and takes one of the session's 16 Unix/TCP connection slots. It
+works in TTY, SDL and detached sessions (the session host starts the shell,
+not an attached frontend). Dump/Restore does not remake the mount.
-Start an updated editor to get the builtin. An already running core retains
-its old code. The host resolves the helper beside its own executable;
-`PARDES_V9FS_HELPER=/absolute/path/to/pardes-v9fs` overrides this for development
-builds whose editor and helper live in different build-cache directories.
+## Setup
-Linux must support `9p` and its Unix transport (`9pnet_fd`). v9fs mounting needs
-`CAP_SYS_ADMIN` in the initial user namespace, which sudo supplies. The launcher
-uses `sudo -E` to retain the shell environment; local sudo policy must allow
-that. It restores the caller's PATH after dropping privileges because sudo's
-`secure_path` can replace it even with `-E`.
+The build installs `pardes-v9fs` beside `pardes`; the host looks for it
+beside its own executable, or at `PARDES_V9FS_HELPER` (an absolute path).
+A running editor keeps the code it started with.
-No setuid installation, passwordless sudo policy, system group, or FUSE is
-installed. The helper currently accepts explicit mount paths and a command;
-it is not a restricted privilege broker. Do not grant it blanket passwordless
-access. A group-authorized helper would require a separate restricted design.
+Linux needs `9p` and its Unix transport (`9pnet_fd`); mounting needs
+`CAP_SYS_ADMIN`, which sudo supplies. The launcher runs `sudo -E` (local
+policy must allow it) and restores the caller's `PATH` after dropping
+privileges. Nothing setuid, no passwordless sudo rule and no FUSE is
+installed. The helper takes explicit mount paths and a command; it is not a
+restricted privilege broker, so do not grant it passwordless sudo.
-## Runtime organization
+## How it works
-`src/linux/v9fs.zig` builds `pardes-v9fs` and provides helper discovery to the
-native host. `Tty9p` marks the new pane for a mounted shell; `host_io.forkShell`
-starts the normal interactive shell and queues a quoted helper command. For
-bash and fish, the command waits for the shell's prompt-ready mark. The helper
-runs as a foreground shell job, so sudo uses the terminal like a manually run
-command. Authentication failure or cancellation returns to the original shell;
-exiting the mounted shell also returns there.
+`Tty9p` starts the normal shell and queues a quoted helper command, which
+bash and fish run once their prompt is ready, as a foreground job, so sudo
+uses the terminal. A failed or cancelled authentication returns to that
+shell, as does exiting the mounted one. The unprivileged launcher makes a
+private temporary mountpoint and runs sudo; the elevated helper makes a
+private mount namespace, mounts the session's socket with
+`trans=unix,version=9p2000,cache=none,access=any,nosuid,nodev,noexec`, drops
+every root id and capability, and executes the shell. The namespace, and
+the mount, go when its last process exits. Code: `src/linux/v9fs.zig`.
-The unprivileged launcher creates a private temporary mountpoint and invokes
-sudo inside the new PTY. The elevated helper creates a private mount namespace,
-makes propagation recursively private, and mounts the session's Unix socket
-with `version=9p2000,cache=none,access=any,nosuid,nodev,noexec`. It restores the
-calling user's account groups, drops all real/effective/saved root IDs and
-mount capabilities, and executes the shell. Ordinary commands such as sudo
-remain available for subsequent explicit authentication. The launcher waits for sudo,
-forwards termination signals, and removes its empty temporary directory on exit.
-Namespace destruction releases the mount when its last process exits.
-
-The core stays outside the mount namespace, which is the helper's private
-one: a path under `$PARDES_MOUNT` names nothing in the editor's own namespace,
-so a Look on one from that shell opens nothing.
-
-Each `Tty9p` currently creates its own mount and consumes a server connection;
-the session has four application connection slots across all transports. This
-is the explicit per-pane version. A shared launcher for all pane shells remains
-future work. Detached sessions retain the running mounted pane when frontends
-leave; Dump/Restore does not reconstruct mounted-shell namespaces. Descendants
-that deliberately outlive the terminal may retain their namespace until exit.
+Linux follows `O_TRUNC` with a `Twstat` of zero length and an `mtime` hint;
+pardes takes the truncation and drops the hint.
## Tests
```sh
-zig build v9fs-terminal-test -Dplatform=tty
-zig build v9fs-driver-test -Dplatform=tty
-zig build 9p-test -Dplatform=tty
-sudo -v
-zig build v9fs-test -Dplatform=tty
+zig build v9fs-terminal-test -Dplatform=tty # builtin, launcher, cleanup; a sudo stand-in, no mount
+zig build v9fs-driver-test -Dplatform=tty # the probe launcher, no privileges
+sudo -v; zig build v9fs-test -Dplatform=tty # a real kernel mount (sudo -n); fails, not skips, without it
```
-
-`v9fs-terminal-test` exercises the builtin, real launcher, PTY input, session
-environment, quoted helper paths, hidden password input, and core responsiveness
-using an unprivileged sudo stand-in. It checks that authentication failure and
-interruption clean up the temporary mountpoint and leave the original shell usable. It does not claim kernel-mount coverage.
-
-`v9fs-test` mounts through the same runtime helper. Its driver keeps the editor
-unprivileged and uses `sudo -n`, retaining the calling terminal's authorization.
-Missing authorization or kernel support fails the test instead of skipping it.
-It checks mount isolation, privilege dropping, inherited access, directory
-refresh, body reads and truncation, independent wire updates, addressed edits,
-shell redirection to name, Exec dispatch, rendered screen JSON, and OS-file reads.
-
-Linux follows `O_TRUNC` with a `Twstat` carrying zero length and an `mtime` hint.
-Pardes accepts this truncation without storing caller-selected timestamps;
-standalone timestamp, permission, and ownership changes remain unsupported.
-
-The initial kernel probe passed on this host on 2026-09-14. The broader
-`fs-test` has an existing syntax-bold assertion failure (now `test/fs.py:615`),
-also reproduced on the cached editor binary preceding the truncation fix.
diff --git a/src/9p.zig b/src/9p.zig
index 5ffe9058..837525cb 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min;
/// The smallest msize a native listener offers (src/9p_io.zig).
pub const min_msize: u32 = 4096;
/// What a native listener negotiates.
-pub const msize: u32 = 8192;
+pub const msize: u32 = 65536;
/// The editor's engine: native fid count, native file names, and room to
/// park a write of any size a frame can carry -- a request that would change
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 91240b57..78ff5d59 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -261,6 +261,7 @@ pub const Listener = struct {
return conn.reply(&later, "");
}
const core = l.core;
+ core.fs.write_room = if (conn.engine.protocol.msize > 24) conn.engine.protocol.msize - 24 else 0;
const epoch = pardes.turn.epoch;
const restores = pardes.turn.restores;
// Asked first: a release that runs a held line has none after.
@@ -311,11 +312,14 @@ pub const Listener = struct {
if (restoring) l.restore_writer.store(true, .release);
defer if (restoring) l.restore_writer.store(false, .release);
core.fs.late_failure_len = 0;
+ // What fails while this waits is this write's err, never a msg too.
+ core.fs.write_waits = true;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
if (core.fs.lsp_answer_at) |n| {
core.fs.lsp_answer_at = null;
pardes.turn.awaitLsp(n);
}
+ core.fs.write_waits = false;
// ponytail: one slot, so a failure of another client's effects that
// settle in the same wait is told to this write too.
if (l.core == core and core.fs.late_failure_len != 0) {
@@ -326,9 +330,8 @@ pub const Listener = struct {
pardes.ctlfs.E.NOENT
else if (std.mem.indexOf(u8, late, "no space") != null) pardes.ctlfs.E.NOSPC else pardes.ctlfs.E.IO;
const failed = pardes.ctlfs.failText(req.tag, errno, late);
- // Its err record says it (the path in it); the msg the failure
- // was also said as goes, as a builtin's failing a write does.
- pardes.ctlfs.events.dropMessage(core, core.fs.late_failure[0..core.fs.late_failure_len]);
+ // Its err record says it (the path in it), the one record: it
+ // was said with no msg while this waited (fs.write_waits).
pardes.ctlfs.events.noteError(core, req, failed);
return conn.reply(&failed, "");
}
@@ -506,6 +509,7 @@ pub const Listener = struct {
/// Answers one QUIC request, on the editor's thread.
fn step(l: *Listener, srv: *Srv, req: pardes.ctlfs.Req) void {
+ l.core.fs.write_room = if (srv.protocol.msize > 24) srv.protocol.msize - 24 else 0;
const reply = l.core.serveFs(req);
srv.reply(&reply, l.core.fsPayload(reply));
if (req.op == .release) l.collectOs();
@@ -1943,14 +1947,8 @@ pub const Client = struct {
}
};
- fn transact(
- s: *Session,
- names: []const []const u8,
- out: *std.Io.Writer.Allocating,
- remote: *RemoteError,
- write_bytes: ?[]const u8,
- read_limit: usize,
- ) !void {
+ /// Version, attach and a walk to `names`: the fid there and its qid.
+ fn walkTo(s: *Session, names: []const []const u8, remote: *RemoteError) !struct { fid: u32, qid: ninep.Qid } {
_ = try s.ask(.{ .version = .{} }, remote);
if (s.cl.msize == 0) return Error.Botch;
@@ -1974,6 +1972,20 @@ pub const Client = struct {
next = if (next == 1) 2 else 1;
i += n;
}
+ return .{ .fid = cur, .qid = here };
+ }
+
+ fn transact(
+ s: *Session,
+ names: []const []const u8,
+ out: *std.Io.Writer.Allocating,
+ remote: *RemoteError,
+ write_bytes: ?[]const u8,
+ read_limit: usize,
+ ) !void {
+ const at = try walkTo(s, names, remote);
+ const cur = at.fid;
+ const here = at.qid;
defer s.dropNoWait(cur);
const directory = here.type & ninep.qtdir != 0;
@@ -2032,15 +2044,20 @@ pub const Client = struct {
read_limit: usize,
) ![]u8 {
if (comptime !supported) return Error.Dial;
+ const s = try startSession(gpa, sock, display_path);
+ defer endSession(gpa, s);
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ errdefer out.deinit();
+ try transact(s, names, &out, remote, write_bytes, read_limit);
+ return out.toOwnedSlice();
+ }
+
+ /// A connected session whose requests have the usual deadline.
+ fn startSession(gpa: std.mem.Allocator, sock: Dial, display_path: []const u8) !*Session {
const deadline = nowMs() +| budget_ms;
const s = try gpa.create(Session);
s.* = .{ .fd = -1, .deadline = deadline, .display_path = display_path };
- defer {
- if (quic_enabled and s.quic != null) {
- s.quic.?.deinit();
- } else if (s.fd >= 0) _ = libc.close(s.fd);
- gpa.destroy(s);
- }
+ errdefer endSession(gpa, s);
if (sock == .quic) {
if (comptime quic_enabled) {
s.quic = quic.Connection.dial(sock.quic) catch return Error.Dial;
@@ -2048,11 +2065,48 @@ pub const Client = struct {
} else return Error.QuicUnavailable;
} else s.fd = try connect(sock, deadline);
s.cl = .init(.{ .in = &s.in, .out = &s.out });
+ return s;
+ }
- var out: std.Io.Writer.Allocating = .init(gpa);
- errdefer out.deinit();
- try transact(s, names, &out, remote, write_bytes, read_limit);
- return out.toOwnedSlice();
+ fn endSession(gpa: std.mem.Allocator, s: *Session) void {
+ if (quic_enabled and s.quic != null) {
+ s.quic.?.deinit();
+ } else if (s.fd >= 0) _ = libc.close(s.fd);
+ gpa.destroy(s);
+ }
+
+ /// Opens `path` on one connection and writes `first` to it (/log's
+ /// `follow new`), all with the usual deadline; then, once `ready(ctx)`
+ /// has said it is not done already, reads it with no deadline, a read at
+ /// a time, until `record(ctx, bytes)` says done. An end of file or a
+ /// dropped connection is Hangup. What `pardes --wait` blocks on.
+ pub fn follow(
+ gpa: std.mem.Allocator,
+ dial: []const u8,
+ path: []const u8,
+ first: []const u8,
+ ctx: anytype,
+ comptime ready: fn (@TypeOf(ctx)) bool,
+ comptime record: fn (@TypeOf(ctx), []const u8) bool,
+ ) !void {
+ if (comptime !supported) return error.Unsupported;
+ var names: [max_depth][]const u8 = undefined;
+ const n = try elements(path, &names);
+ var sock_buf: [sun_path_len]u8 = undefined;
+ const sock = try resolve(&sock_buf, dial);
+ var remote: RemoteError = .{};
+ const s = try startSession(gpa, sock, path);
+ defer endSession(gpa, s);
+ const fid = (try walkTo(s, names[0..n], &remote)).fid;
+ _ = try s.ask(.{ .open = .{ .fid = fid, .mode = ninep.ordwr } }, &remote);
+ _ = try s.ask(.{ .write = .{ .fid = fid, .offset = 0, .data = first } }, &remote);
+ if (ready(ctx)) return;
+ s.deadline = std.math.maxInt(i64);
+ while (true) {
+ const data = (try s.ask(.{ .read = .{ .fid = fid, .offset = 0, .count = s.cl.maxRead() } }, &remote)).read;
+ if (data.len == 0) return Error.Hangup;
+ if (record(ctx, data)) return;
+ }
}
fn connect(sock: Dial, deadline: i64) Error!c_int {
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 142287b2..d2641aa9 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,6 +1,60 @@
# Changelog
-## 0.0.3
+## 0.0.4
+
+- Drive the editor over 9P the way acme is driven. Panes take sam
+ addresses (`/re/`, `#n`, `$-1`, `0/re/`), `L:C` and inclusive `L:C-L2:C2`
+ ranges on `addr`, with `data` and `xdata`; `Edit` runs the sam command
+ language as one undo step. `exec` and `ctl` buffer lines per open, and a
+ line without a newline runs with its write. One rule for failure: a
+ builtin that fails fails the write and logs one `err`, no `msg`.
+- New 9P files: `/layout`, `/tag`, `/tagexec`, `col/<serial>/{tag,ctl,exec}`,
+ `pane/<n>/tagexec`, `/recent`, ask/answer records, and a `size` ctl for
+ detached sessions (160x50 by default). Listings freeze at their open, so
+ a read in several chunks never splices two moments.
+- acme's ctl words (`name`, `put`, `clean`, `dirty`, `del`, `delete`,
+ `dot=addr`, `addr=dot`, `limit=addr`, `mark`, `nomark`, `show`,
+ `cleartag`) are accepted again, done by the files that replace them; a
+ pane's ctl reads acme's fields in acme's order. `Put` saves and `Delete`
+ closes; acme builtins pardes lacks are refused, never run as shell
+ commands. plan9port's `9p write` always truncates, so piping into `body`
+ replaces it: append with `>>` through a mount.
+- Command panes and a REPL binding; `pty/run` runs one line and answers its
+ exit status. Every child starts with default signal dispositions.
+- `Recent` lists recently opened files, closed ones included, and persists
+ in `$XDG_STATE_HOME/pardes/recent`. A Jumplist row for a closed file says
+ `(closed)`. `+Unsaved` lists dirty panes. `ThemeSel` and `FontSel` are now
+ `Themes` and `Fonts`; `Exit`'s refusal opens in an output pane.
+- `pardes --wait FILE` (`-w`) run inside a pane opens FILE there and returns
+ only when its pane is deleted, as plan9port's `E` does: set
+ `EDITOR='pardes --wait'` for fish's Ctrl-O, git and the rest. Without it,
+ `pardes FILE` still hands the file over and returns at once.
+- Only a write logs an `err`: a refused open, create or remove fails with
+ Plan 9 words (`permission denied`, `file does not exist`) and logs nothing.
+- The tag is a full text buffer: multi-line, with undo.
+- Helix keys: the skipped non-conflicting keys, full registers and per-range
+ anchors, checked against the latest helix.
+- Deleting the last column leaves the window empty but for its tag, as in
+ acme. A buffer whose file was deleted on disk counts as dirty.
+- 62 themes in 13 families, each as close as possible to its original,
+ grouped by family in `Themes`; an `acme` theme taken from plan9port's
+ source, with acme's grips, 2 px rules and a 1 px tag rule. Sizes follow
+ display density.
+- GUI motion: `Motion` flavours (crisp, smooth, bouncy, playful, off), `Lift`,
+ a cursor that glides and moves with its pane, smooth scroll, and optional
+ Bloom, Vignette, Grain, SelectionGlow, HoverGlow and Shadertoy post passes
+ that recompile on save. Still passes let the GUI rest; Crt keeps drawing.
+- The terminal: replies to capability queries, malformed escape sequences
+ and lone modifier keys never become typed text; function keys, Insert and
+ the keypad reach terminal panes; a lone Escape is Escape at once; a focus
+ change no longer resends every blank cell.
+- Speed: the theme's chrome is worked out once, not per grapheme; typing
+ tells `g.` where its edit ended; large body and data writes are linear;
+ a pattern that opens with a literal jumps to it. `zig build perf-gate`
+ fails any gesture more than 3x its recorded baseline.
+- Robustness: over-long paths, non-UTF-8 bytes and odd 9P input are refused,
+ never a panic; seeded GUI, core and 9P monkeys (`zig build monkey-9p`)
+ replay and shrink what they find.
- A column can be empty, as in acme. `Newcol` makes an empty column and
gives its tag the keyboard; closing a column's last pane, or dragging it
@@ -63,6 +117,9 @@
carry real lengths, modes and mtimes. Sources are served under `/src` only
with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to
`src/ninep/`.
+
+## 0.0.3
+
- Add pinned tree-sitter declaration context, with optional compact tagline
styling and separators between discontinuous source lines.
- Configure search and LSP result context with `LocationsConfig`. Results use
diff --git a/src/File.zig b/src/File.zig
index 1d838c4f..9f0f329c 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -266,7 +266,36 @@ test "stacked location metadata requires matching adjacent preview ownership" {
pub fn graphemeDisplayWidth(grapheme: []const u8) usize {
if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width;
if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1;
- return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
+ if (std.unicode.utf8ValidateSlice(grapheme)) return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode)));
+ // vaxis's gwidth counts back a replacement rune's 3 bytes over the one
+ // invalid byte it stood for (`\xee` before `e\u{301}` overflowed it):
+ // each invalid byte is a cell, as surface.zig draws it, and each valid
+ // run between is measured alone.
+ var width: usize = 0;
+ var run: usize = 0;
+ var i: usize = 0;
+ while (i < grapheme.len) {
+ const n = std.unicode.utf8ByteSequenceLength(grapheme[i]) catch 0;
+ if (n > 0 and i + n <= grapheme.len and std.unicode.utf8ValidateSlice(grapheme[i .. i + n])) {
+ i += n;
+ continue;
+ }
+ if (run < i) width += vaxis.gwidth.gwidth(grapheme[run..i], .unicode);
+ width += 1;
+ i += 1;
+ run = i;
+ }
+ if (run < grapheme.len) width += vaxis.gwidth.gwidth(grapheme[run..], .unicode);
+ return @max(1, width);
+}
+
+test "an invalid byte in a grapheme is one cell, and never reaches vaxis's width" {
+ try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xeee\xcc\x81"));
+ try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\xee\xffe"));
+ try std.testing.expectEqual(@as(usize, 3), graphemeDisplayWidth("\u{4e16}\x80"));
+ // The whole path the 9P fuzzer took: a body holding it, fitted to a width.
+ const text = "\xeee\xcc\x81";
+ try std.testing.expectEqual(@as(usize, 4), fitEnd(text, 0, 10));
}
pub fn byteDisplayWidth(byte: u8) usize {
@@ -433,8 +462,11 @@ test "the ASCII arm of graphemeDisplayWidth matches the gwidth it skips" {
}
for ([_][]const u8{
"e\u{301}", "a\u{903}", "1\u{fe0f}\u{20e3}", "\u{4e16}",
- "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff", "\xe4\xb8",
+ "\u{1f642}", "\u{1f1e6}\u{1f1e7}", "\xff",
}) |g| try std.testing.expectEqual(ref(g), graphemeDisplayWidth(g));
+ // Two invalid bytes are two cells, as surface.zig draws them, where
+ // vaxis's own count gives the pair one.
+ try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("\xe4\xb8"));
}
test "the ASCII run in fitEnd survives an exhaustive byte sweep" {
diff --git a/src/Messages.zig b/src/Messages.zig
index aebaff0c..42200863 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -503,18 +503,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failures +%= 1;
- // A builtin a ctl write runs: its first error is also the write's, cut
- // between words.
- const failing_write = p.fs.no_prompt or p.fs.capturing;
+ // A builtin a ctl write runs: its first error is also the write's, its
+ // path shortened in the middle if it must be, never its reason.
+ const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits;
if (failing_write and p.fs.failure_len == 0) {
- const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
+ var room: @TypeOf(p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(text, &room);
@memcpy(p.fs.failure[0..kept.len], kept);
- var n = kept.len;
- if (kept.len < text.len) {
- @memcpy(p.fs.failure[n..][0..3], "...");
- n += 3;
- }
- p.fs.failure_len = @intCast(n);
+ p.fs.failure_len = @intCast(kept.len);
}
// The write fails with it, and its err record says it: no msg for it,
// so the same failure again is the same record again, counted
diff --git a/src/builtins.zig b/src/builtins.zig
index 8788e65f..8a01b3d6 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -562,7 +562,9 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
}
if (count > 0) {
listed.print(arena, "\n{s} again to discard\n", .{@tagName(asking)}) catch {};
- if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {}
+ // A refused Delcol changes nothing, the layout included: its records
+ // and its notice say it, and no +Unsaved opens.
+ if (asking != .Delcol) if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {};
var short: [96]u8 = undefined;
c.p.reportFailure(c.id, std.fmt.bufPrint(&short, "{d} unsaved pane{s} \u{2014} {s} again to discard", .{ count, if (count == 1) "" else "s", @tagName(asking) }) catch "unsaved panes");
}
@@ -572,7 +574,8 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) })
else
std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified";
- const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len);
+ var room: @TypeOf(c.p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(said, &room);
@memcpy(c.p.fs.failure[0..kept.len], kept);
c.p.fs.failure_len = @intCast(kept.len);
}
@@ -1372,11 +1375,12 @@ pub const Pet = struct {
/// Open a minimap of the file in braille dots.
pub const Mini = struct {
pub const takes_arg = true;
+ pub const requires_arg = true; // a file to map: bare, wrong #args (EINVAL)
pub const output: OutputTraits = .{ .name = "Mini", .doc = true };
pub fn run(c: Ctx) void {
panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err|
- c.p.reportError(c.id, "mini", err);
+ c.p.reportError(c.id, "Mini", err);
}
};
@@ -1384,8 +1388,8 @@ pub const Mini = struct {
/// A Find or Grep that finds nothing fails as one, in words 9ns reads as
/// ENOENT (`not found`), the same for both.
-const e_search_miss_find = "find: name not found";
-const e_search_miss_grep = "grep: pattern not found";
+const e_search_miss_find = "Find: name not found";
+const e_search_miss_grep = "Grep: pattern not found";
/// List the files below this pane's directory whose names hold its argument, in a +Search.
pub const Find = struct {
@@ -1395,7 +1399,7 @@ pub const Find = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) {
- const rows = look.runSearch(c.p, c.id, pat, .find, .top, false) catch |err| return c.p.reportError(c.id, "find", err);
+ const rows = look.runSearch(c.p, c.id, pat, .find, .top, false) catch |err| return c.p.reportError(c.id, "Find", err);
if (rows == 0) c.p.reportFailure(c.id, e_search_miss_find);
return;
}
@@ -1412,7 +1416,7 @@ pub const Grep = struct {
pub fn run(c: Ctx) void {
const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (pat.len > 0) {
- const rows = look.runSearch(c.p, c.id, pat, .grep, .top, false) catch |err| return c.p.reportError(c.id, "grep", err);
+ const rows = look.runSearch(c.p, c.id, pat, .grep, .top, false) catch |err| return c.p.reportError(c.id, "Grep", err);
if (rows == 0) c.p.reportFailure(c.id, e_search_miss_grep);
return;
}
@@ -2002,7 +2006,7 @@ test "Exit and Restore name a modified +New scratch as well as files" {
// Delcol, Restore and Exit name it the same way.
p.fs.failure_len = 0;
try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
- try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") != null);
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") == null);
try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), named[0 .. config.scratch_buffer.len + 7]) != null);
}
@@ -2015,7 +2019,8 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
const serial = pane.serial;
try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
try std.testing.expect(p.paneBySerial(serial) != null);
- try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDelcol again to discard\n"));
+ // A refused Delcol changes nothing: no +Unsaved (its records say it).
+ try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol") == null);
// Each word is warned on its own: Del asks too, then closes.
try std.testing.expect(p.executeBuiltinLine(p.active, "Del"));
try std.testing.expect(p.paneBySerial(serial) != null);
diff --git a/src/dump.zig b/src/dump.zig
index fca88a2d..a2efb95d 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -31,7 +31,8 @@ const Pardes = pardes.Pardes;
/// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness
/// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share
/// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other.
-/// Creates the pardes dir (parents assumed; a failure surfaces at open).
+/// Creates the directory and any parent missing (a fresh HOME has no
+/// ~/.local/share); a failure surfaces at open.
pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
if (std.c.getenv("PARDES_DUMP")) |p|
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null;
@@ -40,7 +41,13 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
var dz: [901:0]u8 = undefined;
@memcpy(dz[0..dir.len], dir);
dz[dir.len] = 0;
- _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine
+ // Each missing directory on the way, as `mkdir -p`: EEXIST is fine.
+ for (dz[1..dir.len], 1..) |c, i| if (c == '/') {
+ dz[i] = 0;
+ _ = std.c.mkdir(dz[0..i :0], 0o755);
+ dz[i] = '/';
+ };
+ _ = std.c.mkdir(dz[0..dir.len :0], 0o755);
var ts: std.c.timespec = undefined;
_ = std.c.clock_gettime(.REALTIME, &ts);
const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) };
@@ -109,6 +116,21 @@ pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null;
}
+test "a dump makes every directory missing on the way to its DumpDir" {
+ if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var at: [4096]u8 = undefined;
+ const base = at[0..try tmp.dir.realPath(std.testing.io, &at)];
+ var setting: [4200]u8 = undefined;
+ const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base});
+ var out: [1024:0]u8 = undefined;
+ const path = outPath(&out, dir).?;
+ try std.testing.expect(std.mem.startsWith(u8, path, dir));
+ var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{});
+ made.close(std.testing.io);
+}
+
test "a DumpDir reads back absolute, however it was written" {
var buf: [4096]u8 = undefined;
try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?);
@@ -129,7 +151,7 @@ pub fn defaultDirectory(buf: []u8) ?[]const u8 {
pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = MAX_PANES;
-pub const max_cols: usize = 6;
+pub const max_cols: usize = MAX_COLS;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
/// without importing the output-pane implementation. The bound itself is
@@ -987,6 +1009,24 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor
try std.testing.expect(!restored.settings.verbose);
}
+test "a session with every column it may hold dumps and restores them all" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 });
+ defer p.deinit();
+ while (p.ncol < MAX_COLS) {
+ layout.compute(p);
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn;
+ }
+ try dumpState(p);
+ const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol);
+}
+
test "a restored pane keeps its dot, as acme's dump keeps a window's" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
diff --git a/src/exec.zig b/src/exec.zig
index 06e5d375..2c42389c 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -811,9 +811,42 @@ pub fn namesBuiltin(txt: []const u8) bool {
return std.meta.stringToEnum(Builtin, cmd[0..end]) != null;
}
+/// acme's words, run by their pardes names (Put is Save; Delete is a Del
+/// that does not ask), or refused, never run as a shell command: a Put that
+/// exited 127 looked like a save that worked.
+pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint: []const u8 = "" }{
+ .{ .acme = "Put", .pardes = "Save" },
+ .{ .acme = "Delete", .pardes = "Del" },
+ .{ .acme = "Get", .pardes = null, .hint = ": write get to the pane's ctl" },
+ .{ .acme = "Putall", .pardes = null, .hint = ": Save each pane" },
+ .{ .acme = "Snarf", .pardes = null, .hint = ": y yanks the selection" },
+ .{ .acme = "Cut", .pardes = null, .hint = ": d deletes the selection into a register" },
+ .{ .acme = "Paste", .pardes = null, .hint = ": p puts a register" },
+ .{ .acme = "Zerox", .pardes = null },
+ .{ .acme = "Sort", .pardes = null },
+ .{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" },
+ .{ .acme = "ID", .pardes = null },
+ .{ .acme = "Send", .pardes = null },
+};
+
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
- const cmd = commandText(txt);
- if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false;
+ const raw = commandText(txt);
+ if (raw.len == 0 or p.exec_depth >= max_exec_depth) return false;
+ const word_end = std.mem.indexOfAny(u8, raw, " \t") orelse raw.len;
+ var renamed: [1024 + 16]u8 = undefined;
+ const cmd = for (acme_words) |w| {
+ if (!std.mem.eql(u8, raw[0..word_end], w.acme)) continue;
+ const now = w.pardes orelse {
+ var said: [128]u8 = undefined;
+ p.reportFailure(id, std.fmt.bufPrint(&said, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not");
+ return true;
+ };
+ // Delete: Del, the text thrown away unasked.
+ if (std.mem.eql(u8, w.acme, "Delete")) if (p.panes[id]) |pane| if (pane.file) |f| {
+ pane.discard_warned = .{ .revision = f.revision, .by = .Del };
+ };
+ break std.fmt.bufPrint(&renamed, "{s}{s}", .{ now, raw[word_end..] }) catch raw;
+ } else raw;
// `Word arg`, or for a word that says so, `Word+arg` (`Tty+fish`).
const plus = std.mem.indexOfScalar(u8, cmd, '+');
var sp = std.mem.indexOfAny(u8, cmd, " \t");
@@ -1117,7 +1150,7 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
.scratch, .fs => if (layout.findPane(p, from)) |sf| {
layout.insert(p, sf.col, sf.idx + 1, id);
layout.splitBelow(p, from, nt);
- },
+ } else atFoot(p, id),
.shell => belowParent(p, from, id),
.doc, .errors => pardesDoc(p, from, id),
.command => {
@@ -1127,9 +1160,18 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
}
}
+/// Where a pane goes when the one it would go under is in no column (Delcol
+/// took them all, or it was never placed): the foot of the active column,
+/// else of the last.
+fn atFoot(p: *Pardes, id: usize) void {
+ const c = p.activeColumn() orelse p.ncol - 1;
+ layout.insert(p, c, p.col_n[c], id);
+ if (p.col_n[c] == 1) p.panes[id].?.vweight = 1;
+}
+
fn belowParent(p: *Pardes, from: usize, id: usize) void {
const parent = layout.splitParent(p, from);
- const f = layout.findPane(p, parent).?;
+ const f = layout.findPane(p, parent) orelse return atFoot(p, id);
layout.insert(p, f.col, f.idx + 1, id);
layout.splitBelow(p, parent, p.panes[id].?);
}
diff --git a/src/fs.zig b/src/fs.zig
index 753a4446..edead0ff 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1326,6 +1326,31 @@ pub const Namespace = struct {
/// A 9P write asked a language server something: the answer count it
/// waits to change (`turn.lsp_answers`), so its failure is the write's.
lsp_answer_at: ?u64 = null,
+ /// The most a Twrite of the request being served can carry (msize less
+ /// its header), 0 when unknown: a write shorter than it is the whole of
+ /// what its client wrote, one that fills it may be cut and go on.
+ write_room: u32 = 0,
+ /// A 9P write waits on what the editor performs for it (a Save's disk
+ /// write, a shell, a language server): a failure said meanwhile is that
+ /// write's err, so it posts no msg (Messages.reportFailure).
+ write_waits: bool = false,
+ /// The whole text of the newest counted log record, hashed: its record
+ /// may be cut, and a repeat is only the same whole text again.
+ log_full_hash: u64 = 0,
+ /// Writes one open made in a row into one place of a pane's body (an
+ /// append to `body`, an insert at `data`'s address), held and put in
+ /// as one edit when anything else comes (ninep/pane.zig flushBatch): a
+ /// 10 MB write in 8 KB pieces is one copy and one undo step, not 1280.
+ batch: struct {
+ serial: u32 = 0,
+ node: u64 = 0,
+ handle: u32 = 0,
+ at: usize = 0,
+ bytes: std.ArrayList(u8) = .empty,
+ /// The editor's clock at the last write held: its step puts them in
+ /// once the writes have paused (`batch_pause_ns`).
+ last_ns: u64 = 0,
+ } = .{},
/// The serial of the pane the root's exec or look last ran a line at
/// (the keyboard's), 0 before one: its err record names that pane.
ran_at: u32 = 0,
diff --git a/src/look.zig b/src/look.zig
index 79cbd50d..fe5a3ac1 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1302,6 +1302,41 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } {
return .{ .path = tok[0..colon], .expr = expr };
}
+/// A look at text that names nothing to open: a `./` name is a missing
+/// file, anything else a search for the text as written.
+fn lookUnresolved(p: *Pardes, id: usize, pane: *Pane, trimmed: []const u8) void {
+ // A `./` or `../` name opened nothing: said, ENOENT, not searched
+ // for as a word. What is missing is the pane's directory when
+ // that is gone, else the file named.
+ if (filesystem.dotRelative(trimmed)) {
+ const dir = Pardes.paneDir(pane);
+ var said: [4200]u8 = undefined;
+ const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true;
+ if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory");
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file");
+ }
+ // A raw terminal's clicks are its program's; a look written over
+ // 9P is no keyboard input, and searches whatever the key mode.
+ if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return;
+ if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed);
+ const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| {
+ p.reportError(id, "search", err);
+ return;
+ };
+ if (rows == 0) {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)});
+ }
+ clearNavigationSelection(p, pane);
+ const at = pane.search_row;
+ _ = searchStep(p, id, 1);
+ if (at != null and pane.search_row == at) {
+ pane.search_row = null;
+ _ = searchStep(p, id, 1);
+ }
+}
+
/// Opens (or finds) `path` as Look would, `id`'s own pane when it is empty,
/// and selects what `expr` addresses there from its dot. An address that
/// does not evaluate moves nothing and says why (acme: no jump).
@@ -1321,9 +1356,18 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
const by_serial = std.mem.startsWith(u8, path, config.pane_addr);
if (by_serial and open == null) return missed(p, id, "no pane {s}", .{path[0..@min(path.len, 32)]});
if (open) |o| target = o else if (path.len > 0) {
+ // A path that names nothing to open: the whole of what was written
+ // is looked for, as `zzq:2` is, and a miss quotes it as written.
+ if (!by_serial and resolveLookTarget(p, id, path, &realbuf) == .none)
+ return lookUnresolved(p, id, p.panes[id] orelse return, path.ptr[0 .. path.len + 1 + expr.len]);
const before = p.active;
+ const failures = p.fs.failures;
+ const was_missed = p.look_missed;
lookAt(p, id, path);
target = p.active;
+ // Look said why it opened nothing (its placement refused, say):
+ // that one failure is the look's, no miss of the address after it.
+ if (p.fs.failures != failures or (p.look_missed and !was_missed)) return;
// The path opened nothing (a search, or no such file): Look said so.
if (target == before and target != id) return;
}
@@ -1334,11 +1378,14 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
};
if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
+ // A miss names the file it was looked for in, and is that file's pane's.
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ const name = missText(f.path, &cut);
const r = a.address(ninep_pane.dotOf(pane)) orelse {
- return missed(p, id, "{s}", .{a.err});
+ return missed(p, target, "{s}: {s}", .{ name, a.err });
};
if (a.i < expr.len or r.q0 > r.q1) {
- return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"});
+ return missed(p, target, "{s}: {s}", .{ name, if (r.q0 > r.q1) "addresses out of order" else "bad address syntax" });
}
clearNavigationSelection(p, pane);
ninep_pane.setDot(pane, r);
@@ -1481,39 +1528,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
pane.sel = @splat(.{});
}
switch (found) {
- .none => {
- // A `./` or `../` name opened nothing: said, ENOENT, not searched
- // for as a word. What is missing is the pane's directory when
- // that is gone, else the file named.
- if (filesystem.dotRelative(trimmed)) {
- const dir = Pardes.paneDir(pane);
- var said: [4200]u8 = undefined;
- const gone = if (comptime pardes.hosted) if (filesystem.localPath(dir)) |local| !exec.isDirectory(local) else false else true;
- if (gone) return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such directory", .{dir[0..@min(dir.len, 4096)]}) catch "look: no such directory");
- var cut: [miss_path_cap + "…".len]u8 = undefined;
- return p.reportFailure(id, std.fmt.bufPrint(&said, "look: {s}: no such file", .{missText(trimmed, &cut)}) catch "look: no such file");
- }
- // A raw terminal's clicks are its program's; a look written over
- // 9P is no keyboard input, and searches whatever the key mode.
- if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return;
- if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed);
- const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| {
- p.reportError(id, "search", err);
- return;
- };
- if (rows == 0) {
- var cut: [miss_path_cap + "…".len]u8 = undefined;
- return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)});
- }
- clearNavigationSelection(p, pane);
- const at = pane.search_row;
- _ = searchStep(p, id, 1);
- if (at != null and pane.search_row == at) {
- pane.search_row = null;
- _ = searchStep(p, id, 1);
- }
-
- },
+ .none => lookUnresolved(p, id, pane, trimmed),
.pane => |t| {
// `@p<serial>` names no open pane: a miss, said.
if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]});
@@ -1570,7 +1585,11 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err);
if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
+ const failures = p.fs.failures;
exec.placeNew(p, id, id, free, .doc);
+ // Refused a place: that is the look's one failure, not a
+ // miss of its line too.
+ if (p.fs.failures != failures) return;
layout.compute(p);
nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
if (target.at.end_line != 0) {
diff --git a/src/main.zig b/src/main.zig
index aaa237f6..ab6f1527 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -58,6 +58,12 @@ const help_text =
\\ A FILE argument boots just that file instead.
\\ --tty-toggle <key> use Ctrl-<key> to toggle tty/editor mode
\\ -l <dump.zon> load a dump of another instance (see Dump)
+ \\ -w, --wait inside a pardes pane, hand FILE to the outer
+ \\ session and return only once the pane it opened
+ \\ (or already had) is deleted: 0 then, 1 if the
+ \\ session goes away. For $EDITOR (acme's E):
+ \\ EDITOR='pardes --wait', which GIT_EDITOR follows.
+ \\ Outside pardes a session blocks anyway.
\\ --nested run a full session even inside another pardes.
\\ Without it, a pardes started inside a pardes
\\ hands its FILE argument to the outer one. This
@@ -92,6 +98,81 @@ const help_text =
\\
;
+/// `--wait` after a forwarded look, as plan9port's E reads acme's log: the
+/// pane `path` landed in (the one it already had, if open), then /log
+/// followed on one connection until that pane's `del` -- exit 0 -- or the
+/// connection ends with the session -- exit 1.
+fn waitForDel(io: std.Io, gpa: std.mem.Allocator, dial: []const u8, path: []const u8) noreturn {
+ const first = ninep_io.Client.read(gpa, dial, "/index", "/index") catch std.process.exit(1);
+ const serial = paneShowing(first, path) orelse {
+ std.Io.File.stderr().writeStreamingAll(io, "pardes: --wait: no pane shows that file\n") catch {};
+ std.process.exit(1);
+ };
+ gpa.free(first);
+ const Wait = struct {
+ gpa: std.mem.Allocator,
+ dial: []const u8,
+ serial: u32,
+ /// Deleted between the look and the follow: /index, read once the
+ /// follow stands, says so, and a `del` after it is followed.
+ fn gone(w: *const @This()) bool {
+ const index = ninep_io.Client.read(w.gpa, w.dial, "/index", "/index") catch return false;
+ defer w.gpa.free(index);
+ return !hasPane(index, w.serial);
+ }
+ fn deleted(w: *const @This(), bytes: []const u8) bool {
+ return isDel(bytes, w.serial);
+ }
+ };
+ const w: Wait = .{ .gpa = gpa, .dial = dial, .serial = serial };
+ ninep_io.Client.follow(gpa, dial, "/log", "follow new", &w, Wait.gone, Wait.deleted) catch std.process.exit(1);
+ std.process.exit(0);
+}
+
+/// Whether log records `bytes` hold `del <serial> ...`.
+fn isDel(bytes: []const u8, serial: u32) bool {
+ var records = std.mem.splitScalar(u8, bytes, '\n');
+ while (records.next()) |rec| {
+ if (!std.mem.startsWith(u8, rec, "del ")) continue;
+ const word = rec[4 .. std.mem.indexOfScalarPos(u8, rec, 4, ' ') orelse rec.len];
+ if ((std.fmt.parseInt(u32, word, 10) catch continue) == serial) return true;
+ }
+ return false;
+}
+
+/// The serial of /index's first row named `path` (`serial kind dirty name col`).
+fn paneShowing(index: []const u8, path: []const u8) ?u32 {
+ var rows = std.mem.splitScalar(u8, index, '\n');
+ while (rows.next()) |row| {
+ var words = std.mem.splitScalar(u8, row, ' ');
+ const serial = std.fmt.parseInt(u32, words.first(), 10) catch continue;
+ _ = words.next() orelse continue; // kind
+ _ = words.next() orelse continue; // dirty
+ const rest = words.rest();
+ const end = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue;
+ if (std.mem.eql(u8, rest[0..end], path)) return serial;
+ }
+ return null;
+}
+
+fn hasPane(index: []const u8, serial: u32) bool {
+ var rows = std.mem.splitScalar(u8, index, '\n');
+ while (rows.next()) |row| {
+ const first = row[0 .. std.mem.indexOfScalar(u8, row, ' ') orelse row.len];
+ if ((std.fmt.parseInt(u32, first, 10) catch continue) == serial) return true;
+ }
+ return false;
+}
+
+test "--wait finds the pane /index names a file by, spaces and all, and sees it gone" {
+ const index = "1 term 0 /home/u 1\n4 text 1 /tmp/a b.txt 2\n";
+ try std.testing.expectEqual(@as(?u32, 4), paneShowing(index, "/tmp/a b.txt"));
+ try std.testing.expectEqual(@as(?u32, null), paneShowing(index, "/tmp/a"));
+ try std.testing.expect(hasPane(index, 4) and !hasPane(index, 2));
+ try std.testing.expect(isDel("new 5 /x\ndel 4 /tmp/a b.txt\n", 4));
+ try std.testing.expect(!isDel("del 41 /x\n", 4) and !isDel("undel 4\n", 4));
+}
+
fn badArgs(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
var buf: [1024]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "pardes: " ++ fmt ++ "\nTry 'pardes --help'.\n", args) catch
@@ -159,6 +240,7 @@ fn nativeMain(init: std.process.Init) !void {
var detach: ?[]const u8 = null;
var attach: ?[]const u8 = null;
var positional: ?[:0]const u8 = null;
+ var wait = false;
var i: usize = 1;
while (i < args.len) : (i += 1) {
const a = args[i];
@@ -239,6 +321,8 @@ fn nativeMain(init: std.process.Init) !void {
mounts_len += 1;
} else if (std.mem.eql(u8, a, "--nested")) {
opts.nested = true;
+ } else if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--wait")) {
+ wait = true;
} else if (std.mem.eql(u8, a, "--detach")) {
detach = "";
} else if (std.mem.startsWith(u8, a, "--detach=")) {
@@ -324,10 +408,31 @@ fn nativeMain(init: std.process.Init) !void {
if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding;
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
- const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path;
- var command_buf: [8192]u8 = undefined;
- const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ var newbuf: [4096]u8 = undefined;
+ const found_path: ?[]const u8 = if (pardes.filesystem.isVirtual(target.path)) target.path else if (pardes.filesystem.resolveOs(target.path, &realbuf)) |r| r.path else null;
+ const path = found_path orelse named: {
+ // A name not there yet, as acme's B takes one: its directory
+ // resolved and the name kept, a pane made for it that Save
+ // creates the file from.
+ const base = std.fs.path.basename(target.path);
+ if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
+ const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding;
+ break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
+ };
+ if (found_path != null) {
+ var command_buf: [8192]u8 = undefined;
+ const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ } else {
+ const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding;
+ const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding;
+ var name_buf: [64]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial});
+ var line_buf: [4200]u8 = undefined;
+ const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding;
+ }
+ if (wait) waitForDel(init.io, arena, parent.dial, path);
return;
}
if (positional) |a| {
@@ -383,7 +488,15 @@ fn nativeMain(init: std.process.Init) !void {
std.process.exit(1);
}
switch (pardes.platform) {
- .tty => try @import("tty/tty.zig").run(init, opts, attach),
+ .tty => @import("tty/tty.zig").run(init, opts, attach) catch |err| switch (err) {
+ // No controlling terminal (a detached pty, a daemon): said, not
+ // an error trace.
+ error.NoDevice => {
+ std.Io.File.stderr().writeStreamingAll(init.io, "pardes: no terminal to draw on: /dev/tty will not open; run it in a terminal, or --detach\n") catch {};
+ std.process.exit(1);
+ },
+ else => return err,
+ },
.gui => try @import("gui/gui.zig").run(init, opts, attach),
.web, .macos, .esp32p4 => unreachable,
}
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index f60c07b1..33a3f646 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -17,7 +17,7 @@ pub const e_col_zero = "address out of range: a column counts from 1";
pub const e_regexp = "bad regular expression";
/// Not "took too long": 9ns reads errors by their words, and that would be
/// ENAMETOOLONG.
-pub const e_slow = "regular expression search gave up, backtracking past its step budget";
+pub const e_slow = "regular expression search took too much time, gave up";
pub const e_syntax = "bad address syntax";
pub const e_order = "addresses out of order";
@@ -299,7 +299,13 @@ pub const Addr = struct {
return null;
}
var rx = regexp_.Regex.compile(pat) catch |err| {
- a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
+ a.err = switch (err) {
+ error.Anchor => regexp_.Regex.e_anchor,
+ error.TooLong => regexp_.Regex.e_long,
+ error.WideRange => regexp_.Regex.e_wide,
+ error.NegatedRunes => regexp_.Regex.e_negated,
+ error.Bad => e_regexp,
+ };
return null;
};
// sam's nextmatch (editors/sam/address.c:97-119): an empty match
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index 6b59f9e5..cc583c93 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -368,10 +368,11 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
var rec: [64]u8 = undefined;
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty})));
- // rmdir refuses a column with a pane, and says why.
+ // rmdir refuses a column with a pane, and says why, in its error alone.
const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
try testing.expectEqual(E.NOTEMPTY, full.errno());
- try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty})));
+ try testing.expect(std.mem.startsWith(u8, full.reply.ename, "column not empty"));
+ try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty})));
// exec runs a word as a click in that column's tag: Delcol closes it.
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 397f3339..5bf354eb 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -251,8 +251,9 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i
/// EIO.
fn failureErrno(failure: []const u8) u16 {
if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT;
- // A pattern refused (Edit's too) is malformed input, as 9ns reads it.
- if (std.mem.indexOf(u8, failure, "bad regular expression") != null) return E.INVAL;
+ // A pattern refused (Edit's too) is malformed input, as 9ns reads it;
+ // so is a word this build has not (`invalid: acme's Put ...`).
+ if (std.mem.indexOf(u8, failure, "bad regular expression") != null or std.mem.startsWith(u8, failure, "invalid")) return E.INVAL;
return E.IO;
}
@@ -330,13 +331,13 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
try w.writeByte('\'');
}
-pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48;
+pub const pane_text_cap = 9 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48;
-/// acme's winctlprint: serial, tag length, body length, a reserved zero, the
-/// dirty flag, then the width in cells, the font and the tab width. Then
-/// rio's word for whether the window has the keyboard, `current` or
-/// `notcurrent` (rio(4), wctl), which acme's line has no room for because
-/// acme has one window for input at a time only by the mouse.
+/// acme's winctlprint (plan9port wind.c), field for field: serial, tag
+/// length, body length, isdir (0), the dirty flag, the width in cells, the
+/// font, the tab width, whether Undo has a step, whether Redo has one. Then
+/// pardes's own: rio's word for whether the window has the keyboard,
+/// `current` or `notcurrent` (rio(4), wctl), and a REPL's id.
pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
@@ -352,7 +353,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered();
// While a column's or the workspace's tag has the keyboard, no pane does.
const current = p.panes[p.active] == pane and !p.header_focus;
- w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {};
+ const f = pane_files.fileOf(pane);
+ const undo = if (f) |file| file.history.undo_len > 0 else false;
+ const redo = if (f) |file| file.history.redo_len > 0 else false;
+ w.print(" {d:>11} {d:>11} {d:>11} {s} ", .{ config.tab_width, @intFromBool(undo), @intFromBool(redo), if (current) "current" else "notcurrent" }) catch {};
// A terminal bound as a REPL ends with its id, `python-a`.
var idbuf: [48]u8 = undefined;
const id = exec_line.replId(&idbuf, pane);
@@ -387,7 +391,13 @@ pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []
/// as on a tag; acme's lowercase verbs are the pane ctl's and alias none.
fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply {
const sp = std.mem.indexOfAny(u8, line, " \t");
- const word = line[0 .. sp orelse line.len];
+ const typed = line[0 .. sp orelse line.len];
+ // acme's words: checked as the pardes word they run as, or refused.
+ const word = for (pardes.exec.acme_words) |w| {
+ if (!std.mem.eql(u8, typed, w.acme)) continue;
+ const now = w.pardes orelse return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s} is not a pardes builtin{s}", .{ w.acme, w.hint }) catch "invalid: an acme word pardes has not");
+ break now;
+ } else typed;
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
const b = std.meta.stringToEnum(Builtin, word) orelse {
// A setting this build's frontend cannot show (Lift, GripWidth on a
@@ -777,10 +787,19 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
var it = Messages.init(p, req.data);
- while (it.next()) |line| {
- if (line.len == 0) continue;
+ while (it.next()) |line_in| {
+ if (line_in.len == 0) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
+ // acme's ctl words: done by the file that replaces each, or the
+ // builtin that does it, checked with the rest before any runs.
+ var line = line_in;
+ switch (acmeCtl(p, req, serial, line_in, apply)) {
+ .no => {},
+ .done => continue,
+ .refused => |r| return r,
+ .builtin => |word| line = word,
+ }
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
// Quoting the line, as a builtin's refusal (Save's) does.
@@ -854,6 +873,72 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+/// acme's pane ctl vocabulary (editors/acme/xfid.c xfidctlwrite): each word
+/// done here by the file that replaces it, or run as the builtin that does
+/// it; those with no place here refused with why.
+const AcmeCtl = union(enum) { no, done, refused: Reply, builtin: []const u8 };
+
+fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) AcmeCtl {
+ const id = p.paneBySerial(serial) orelse return .no;
+ const pane = p.panes[id].?;
+ const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len];
+ const rest = std.mem.trim(u8, line[word.len..], " ");
+ const File = tree.PaneFile;
+ // A word written to one of the pane's own files, as acme's is to its.
+ const Into = struct { file: File, data: []const u8 };
+ const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0)
+ .{ .file = .name, .data = rest }
+ else if (std.mem.eql(u8, line, "clean"))
+ .{ .file = .dirty, .data = "0" }
+ else if (std.mem.eql(u8, line, "dirty"))
+ .{ .file = .dirty, .data = "1" }
+ else if (std.mem.eql(u8, line, "mark"))
+ .{ .file = .mark, .data = "1" }
+ else if (std.mem.eql(u8, line, "nomark"))
+ .{ .file = .mark, .data = "0" }
+ else
+ null;
+ if (into) |w| {
+ if (!apply) return .done;
+ const r = pane_files.write(p, .{ .tag = req.tag, .op = .write, .node = tree.Node.of(serial, w.file), .data = w.data }, id, pane, w.file);
+ return if (r.status == .err) .{ .refused = r } else .done;
+ }
+ if (std.mem.eql(u8, line, "put")) return .{ .builtin = "Save" };
+ if (std.mem.eql(u8, line, "del")) return .{ .builtin = "Del" };
+ if (std.mem.eql(u8, line, "delete")) return .{ .builtin = "Delete" };
+ const pf = &pane.fs;
+ if (std.mem.eql(u8, line, "dot=addr")) {
+ if (apply) pane_files.setDot(pane, pf.addr);
+ return .done;
+ }
+ if (std.mem.eql(u8, line, "addr=dot")) {
+ if (apply) pf.addr = pane_files.dotOf(pane);
+ return .done;
+ }
+ if (std.mem.eql(u8, line, "limit=addr")) {
+ if (apply) pf.limit = pf.addr;
+ return .done;
+ }
+ if (std.mem.eql(u8, line, "show")) {
+ if (apply) pane_files.showOffset(pane, pane_files.dotOf(pane).q0);
+ return .done;
+ }
+ if (std.mem.eql(u8, line, "cleartag")) {
+ if (apply) _ = pane_files.truncate(p, pane, .tag);
+ return .done;
+ }
+ const none: ?[]const u8 = if (std.mem.eql(u8, word, "dump") or std.mem.eql(u8, word, "dumpdir"))
+ "a pane is dumped with the session: Dump and DumpDir on /ctl"
+ else if (std.mem.eql(u8, word, "font"))
+ "the font is the session's: Font on /ctl"
+ else if (std.mem.eql(u8, line, "menu") or std.mem.eql(u8, line, "nomenu"))
+ "pardes has no menu"
+ else
+ null;
+ if (none) |why| return .{ .refused = tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "invalid: acme's {s}: {s}", .{ word, why }) catch "invalid: acme's ctl word") };
+ return .no;
+}
+
const e_not_locked = "pane not locked by this open";
/// Who holds it is another open of this ctl, whose client pardes cannot name.
const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
@@ -922,7 +1007,7 @@ const root_status = @intFromEnum(tree.TopFile.status);
const root_look = @intFromEnum(tree.TopFile.look);
const root_exec = @intFromEnum(tree.TopFile.exec);
-test "pane ctl read is index's five fields plus width in cells, font, tab width and whether it is current" {
+test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -932,8 +1017,8 @@ test "pane ctl read is index's five fields plus width in cells, font, tab width
try testing.expectEqual(Status.ok, a.reply.status);
var want: std.ArrayList(u8) = .empty;
defer want.deinit(gpa);
- try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{
- pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width,
+ try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} {d:>11} {d:>11} current ", .{
+ pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width, 0, 0,
});
try testing.expectEqualStrings(want.items, a.bytes);
try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);
@@ -1034,13 +1119,9 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r
defer p.deinit();
const serial = serialOf(p);
const ctl_node = Node.of(serial, .ctl);
- // acme's other ctl words have files of their own here, and a builtin is
- // its own capitalised word: `Get`, `DEL` and `del` are none of them.
- for ([_][]const u8{
- "menu", "nomenu", "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL",
- "put", "del", "delete", "clean", "dirty", "show", "mark",
- "Get", "limit=addr", "get x", "look /tmp", "exec Del",
- }) |bad| {
+ // A builtin is its own capitalised word: `GET` and `DEL` are none;
+ // acme's own words are done or refused as acme's (the test below).
+ for ([_][]const u8{ "lock x", "bogus", "DEL", "GET", "get x", "look /tmp", "exec Del" }) |bad| {
const refused = wr(p, ctl_node, bad);
try testing.expectEqual(E.INVAL, refused.errno());
var want: [64]u8 = undefined;
@@ -1671,6 +1752,59 @@ test "a look after Newcol fills the empty column the keyboard is on" {
try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]);
}
+test "a look at file:addr whose file is not there is one miss, quoting what was written, in a terminal too" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const looked = Node.of(serialOf(p), .look);
+ for ([_][]const u8{ "zzq:#3", "zzq:2", "zzq:/re/" }) |written| {
+ var line: [64]u8 = undefined;
+ _ = wr(p, looked, try std.fmt.bufPrint(&line, "{s}\n", .{written}));
+ var want: [64]u8 = undefined;
+ try testing.expectEqual(@as(usize, 1), th.logCount(p, try std.fmt.bufPrint(&want, "look: no match for \"{s}\"\n", .{written})));
+ }
+ try testing.expectEqual(@as(usize, 3), th.logCount(p, "\nerr "));
+}
+
+test "a look whose pane is refused a place fails with that alone, not a missing line too" {
+ const gpa = testing.allocator;
+ // A screen with rows for one pane only.
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 5 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile("x\n");
+ while (p.nextEffect()) |_| {}
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" });
+ var dir: [4096]u8 = undefined;
+ const at = dir[0..try tmp.dir.realPath(testing.io, &dir)];
+ var line: [4200]u8 = undefined;
+ const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{at}));
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "no space for a pane") != null);
+ try testing.expectEqual(@as(usize, 1), th.logCount(p, "\nerr "));
+}
+
+test "a look under pardes's placement, from a pane pane/new made after Delcol, opens its file" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" });
+ var dir: [4096]u8 = undefined;
+ const at = dir[0..try tmp.dir.realPath(testing.io, &dir)];
+ var line: [4200]u8 = undefined;
+ const first = serialOf(p);
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Placement\n").reply.status);
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(first, .exec), "Delcol\n").reply.status);
+ const made = try th.newPane(p);
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .look), try std.fmt.bufPrint(&line, "{s}/f.txt:#4\n", .{at})).reply.status);
+ // The pane pane/new made is placed, and so is the one its look opened.
+ for (p.panes, 0..) |slot, i| if (slot != null) try testing.expect(layout.findPane(p, i) != null);
+ try testing.expect(p.panes[p.active].?.serial != made);
+}
+
test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" {
if (comptime !pardes.hosted) return error.SkipZigTest;
const p = try withFile(testing.allocator, "x\n");
@@ -1924,10 +2058,10 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
for (p.panes) |slot| count += @intFromBool(slot != null);
const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n");
try testing.expectEqual(E.NOENT, missed.errno());
- try testing.expectEqualStrings("grep: pattern not found", missed.reply.ename);
+ try testing.expectEqualStrings("Grep: pattern not found", missed.reply.ename);
const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n");
try testing.expectEqual(E.NOENT, unfound.errno());
- try testing.expectEqualStrings("find: name not found", unfound.reply.ename);
+ try testing.expectEqualStrings("Find: name not found", unfound.reply.ename);
var after: usize = 0;
for (p.panes) |slot| after += @intFromBool(slot != null);
try testing.expectEqual(count, after);
@@ -2234,6 +2368,133 @@ test "a buffer whose file was deleted on disk is dirty, and Del asks first" {
try testing.expect(p.paneBySerial(serial) == null);
}
+test "a failure said while a 9P write waits on it posts no msg, only the write's err" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.fs.write_waits = true;
+ p.saveFailed(@intCast(p.active), "/nowhere/zz.txt", error.FileNotFound);
+ p.fs.write_waits = false;
+ try testing.expect(!th.logHas(p, "msg "));
+ try testing.expect(p.fs.late_failure_len > 0);
+}
+
+test "Mini with no argument is wrong #args, EINVAL, as every word that needs one" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const r = wr(p, Node.of(serialOf(p), .ctl), "Mini\n");
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expectEqualStrings("wrong #args in control message \"Mini\"", r.reply.ename);
+}
+
+test "a look that misses file:/re/ names the file, and is logged as its pane's" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "alpha\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
+ p.sync();
+ const f_serial = p.panes[p.active].?.serial;
+ _ = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}/f.txt:/zzqq/\n", .{dir}));
+ var want: [4300]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir })));
+}
+
+test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| {
+ for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| {
+ const r = wr(p, node, line);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "is not a pardes builtin") != null);
+ }
+ }
+ // Put saves, as Save does.
+ _ = wr(p, Node.of(serial, .name), "/tmp/pardes-put.txt\n");
+ const put = wr(p, Node.of(serial, .exec), "Put\n");
+ try testing.expectEqual(Status.ok, put.reply.status);
+ try testing.expect(put.saved);
+ // Delete closes an edited pane without asking.
+ const made = try th.newPane(p);
+ _ = wr(p, Node.of(made, .name), "/tmp/pardes-delete.txt\n");
+ _ = wr(p, Node.of(made, .body), "edited\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Delete\n").reply.status);
+ p.sync();
+ try testing.expect(p.paneBySerial(made) == null);
+}
+
+test "a pane's ctl takes acme's words: done by the files that replace them, or refused with why" {
+ const p = try withFile(testing.allocator, "one two\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "name /tmp/pardes-acme-ctl.txt\n").reply.status);
+ try testing.expectEqualStrings("/tmp/pardes-acme-ctl.txt", pane.file.?.path);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "dirty\n").reply.status);
+ try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n"));
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "clean\n").reply.status);
+ try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n"));
+ _ = wr(p, Node.of(serial, .addr), "/two/");
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\nshow\nnomark\nmark\nlimit=addr\n").reply.status);
+ try testing.expectEqual(@as(u32, 4), pane_files.dotOf(pane).q0);
+ try testing.expect(pane.fs.limit != null);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "cleartag\n").reply.status);
+ for ([_][]const u8{ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono" }) |line| {
+ const r = wr(p, ctl_node, line);
+ try testing.expectEqual(E.INVAL, r.errno());
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "invalid: acme's "));
+ }
+ // Checked whole: a refused word anywhere and nothing ran.
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nmenu\n").errno());
+ try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n"));
+}
+
+test "ctl reads acme's fields in acme's order, undo and redo flags included, then pardes's own" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const fields = struct {
+ fn of(text: []const u8, buf: *[16][]const u8) [][]const u8 {
+ var it = std.mem.tokenizeScalar(u8, text, ' ');
+ var n: usize = 0;
+ while (it.next()) |t| : (n += 1) buf[n] = t;
+ return buf[0..n];
+ }
+ };
+ var buf: [16][]const u8 = undefined;
+ var f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf);
+ try testing.expectEqualStrings("0", f[8]); // nothing to undo
+ try testing.expectEqualStrings("0", f[9]); // nothing to redo
+ try testing.expect(std.mem.eql(u8, f[10], "current") or std.mem.eql(u8, f[10], "notcurrent"));
+ _ = wr(p, Node.of(serial, .body), "two\n");
+ f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf);
+ try testing.expectEqualStrings("1", f[8]);
+ _ = wr(p, ctl_node, "Undo\n");
+ f = fields.of(rd(p, ctl_node, 0, 4096).bytes, &buf);
+ try testing.expectEqualStrings("1", f[9]);
+}
+
+test "a write to addr, limit or mark asks for no frame; one to body does" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ for ([_]tree.PaneFile{ .addr, .limit, .mark }) |file| {
+ p.needs_frame = false;
+ _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, file), .data = if (file == .mark) "1" else "#0" });
+ try testing.expect(!p.needs_frame);
+ }
+ p.needs_frame = false;
+ _ = p.serveFs(.{ .tag = 1, .op = .write, .node = Node.of(serial, .body), .data = "two\n" });
+ try testing.expect(p.needs_frame);
+}
+
test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -2960,7 +3221,7 @@ test "@p<serial>:<address> takes any sam address; a miss and an unknown serial a
}
var line: [64]u8 = undefined;
_ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
- try testing.expect(th.logHas(p, "look: no match for regexp"));
+ try testing.expect(th.logHas(p, ": no match for regexp"));
try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
_ = wr(p, root, "@p999:/foo/\n");
try testing.expect(th.logHas(p, "look: no pane @p999"));
@@ -3020,3 +3281,36 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select
try testing.expectEqual(row_before, pane.body.cur_row);
try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
}
+
+test "a bad command inside an Edit block written whole is named, not the } after it" {
+ const p = try withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const r = wr(p, Node.of(serialOf(p), .ctl), "Edit {\n,s/a/b/\nzq\n}\n");
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "\"}\"") == null);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "z") != null);
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "a refused Delcol changes nothing: no pane, no focus" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n");
+ const made = try th.newPane(p);
+ _ = wr(p, Node.of(made, .name), "/tmp/pardes-delcol-refused.txt\n");
+ _ = wr(p, Node.of(made, .body), "edit\n");
+ p.sync();
+ const col = layout.findPane(p, p.paneBySerial(made).?).?.col;
+ const active = p.active;
+ const active_column = p.active_column;
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(Status.err, wr(p, Node.ofCol(layout.columnSerial(p, col), .ctl), "Delcol\n").reply.status);
+ p.sync();
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ try testing.expectEqual(active, p.active);
+ try testing.expectEqual(active_column, p.active_column);
+ try testing.expect(th.logHas(p, "unsaved "));
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 570cae78..fe7af77e 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -171,11 +171,11 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
pushCounting(p, (if (serial == 0)
std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
- std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return);
+ std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return, said);
}
-/// Records `err <serial|-> <file>: <why>` for a write or truncation that was
-/// refused or failed: through a mount a shell sees only the errno the
+/// Records `err <serial|-> <file>: <why>` for a write that was refused or
+/// failed: through a mount a shell sees only the errno the
/// kernel mapped the reply to (`Invalid argument`), and here is the reason.
/// The log is the one place for it, as acme's `errors` file takes text and
/// answers nothing: a per-pane readable error file would be a second.
@@ -186,7 +186,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
pub fn noteErrCounted(p: *Pardes, text: []const u8) void {
bootFirst(p);
var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined;
- pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return);
+ pushCounting(p, std.fmt.bufPrint(&buf, "err {s}\n", .{text}) catch return, text);
}
pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
@@ -239,7 +239,7 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
c.* = ' ';
};
- pushCounting(p, record);
+ pushCounting(p, record, reason);
}
/// Drops the newest record when it is a msg saying `text` (of any pane)
@@ -256,7 +256,11 @@ pub fn dropMessage(p: *Pardes, text: []const u8) void {
/// counts that one instead: `<record> (x2)`, the count being every time it
/// was said. One a follower has already read is not rewritten: the repeat
/// is a new line carrying the running count, `(x3)`.
-fn pushCounting(p: *Pardes, raw: []u8) void {
+/// `full` is the text before any cut: two records that agree only in what
+/// was cut to fit are no repeat (`fs.log_full_hash`).
+fn pushCounting(p: *Pardes, raw: []u8, full: []const u8) void {
+ const full_hash = std.hash.Wyhash.hash(0, full);
+ defer p.fs.log_full_hash = full_hash;
// A client retrying a write that fails the same way would fill the ring
// with one line, so a repeat of the newest record is that record counted,
// `(x3)`, as +Messages counts its repeats; unless a follower has read it
@@ -272,7 +276,7 @@ fn pushCounting(p: *Pardes, raw: []u8) void {
said = said[0..x];
} else |_| {}
};
- if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record);
+ if (!std.mem.eql(u8, said, record[0 .. record.len - 1]) or p.fs.log_full_hash != full_hash) return pushLog(p, record);
var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined;
const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
@@ -373,6 +377,8 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 {
}
fn pushLog(p: *Pardes, raw: []u8) void {
+ // A record not counted: the next one is no repeat of a counted one.
+ p.fs.log_full_hash = 0;
var clean: [4 * 4096 + 256]u8 = undefined;
const record = sanitize(raw, &clean);
// One record larger than the ring would push every other out and then
@@ -1283,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are
try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out));
}
-test "a long err record is cut between words, with an ellipsis" {
+test "a long err record keeps its end, its middle given up to an ellipsis" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
_ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
@@ -1292,6 +1298,19 @@ test "a long err record is cut between words, with an ellipsis" {
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
- try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ try testing.expect(std.mem.indexOf(u8, record, "…") != null);
+ try testing.expect(std.mem.startsWith(u8, record, "err - ctl: "));
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
+
+test "two failures that agree only in what their records cut are two records, not one counted" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "why " ** 80;
+ p.setMessage(0, long ++ "first");
+ p.setMessage(0, long ++ "second");
+ try testing.expect(!th.logHas(p, "(x2)"));
+ p.setMessage(0, long ++ "second");
+ try testing.expect(th.logHas(p, "(x2)"));
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 9f85a75f..3c1e181e 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -475,6 +475,55 @@ fn joinOnly(pane: *Pane, handle: u32) void {
if (pane.fs.joined_by != 0 and pane.fs.joined_by != handle) pane.fs.joined = null;
}
+/// How long writes in a row may pause before the editor's own step puts
+/// them in: long enough that a stream of 8 KB writes is one edit.
+pub const batch_pause_ns: u64 = 20 * std.time.ns_per_ms;
+
+/// The editor's step: the held writes go in once they have paused.
+pub fn flushPausedBatch(p: *Pardes) void {
+ const b = &p.fs.batch;
+ if (b.bytes.items.len == 0) return;
+ if (p.now_ns != 0 and p.now_ns -| b.last_ns < batch_pause_ns) return;
+ flushBatch(p);
+}
+
+/// Puts the held writes (`fs.batch`) in as one edit: before any other
+/// request, the open's release, or the editor's own step.
+pub fn flushBatch(p: *Pardes) void {
+ const b = &p.fs.batch;
+ if (b.bytes.items.len == 0) return;
+ defer b.bytes.clearRetainingCapacity();
+ const id = p.paneBySerial(b.serial) orelse return;
+ const pane = p.panes[id].?;
+ if (fileOf(pane) == null) return;
+ const before = dotOf(pane);
+ joinOnly(pane, b.handle);
+ const take = spliceBody(p, pane, b.at, b.at, b.bytes.items) orelse return;
+ pane.fs.joined = fileOf(pane).?.revision;
+ pane.fs.joined_by = b.handle;
+ setDot(pane, .{
+ .q0 = shiftOne(before.q0, clip(b.at), 0, clip(take)),
+ .q1 = shiftOne(before.q1, clip(b.at), 0, clip(take)),
+ });
+ if (!pane.fs.noscroll) showOffset(pane, b.at + take);
+}
+
+/// The held bytes a write at `at` on this open can go on from: its own
+/// batch, when it ends right at `at`; any other is put in first.
+fn batchFor(p: *Pardes, req: Req, pane: *Pane, at: usize) ?*@TypeOf(p.fs.batch) {
+ const b = &p.fs.batch;
+ const mine = b.serial == pane.serial and b.node == req.node and b.handle == req.handle;
+ if (b.bytes.items.len > 0 and !(mine and b.at + b.bytes.items.len == at)) flushBatch(p);
+ // Only an open's writes are held: one with no record (a test's, handle
+ // 0) is put in at once. A hold past 64 MiB is put in first.
+ if (req.handle == 0 or b.bytes.items.len > 64 << 20) {
+ flushBatch(p);
+ return null;
+ }
+ if (b.bytes.items.len == 0) b.* = .{ .serial = pane.serial, .node = req.node, .handle = req.handle, .at = at, .bytes = b.bytes };
+ return b;
+}
+
fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
if (pane.file == null) {
@@ -484,7 +533,14 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
p.emitWrite(id, req.data[0..take]);
return .{ .tag = req.tag, .written = @intCast(take) };
}
- const at = bodyOf(pane).len;
+ const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0;
+ const at = bodyOf(pane).len + held;
+ if (batchFor(p, req, pane, at)) |b| {
+ const take = wholeUtf8(req.data);
+ b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM);
+ b.last_ns = p.now_ns;
+ return .{ .tag = req.tag, .written = @intCast(take) };
+ }
joinOnly(pane, req.handle);
const take = spliceBody(p, pane, at, at, req.data) orelse
return Reply.fail(req.tag, E.NOMEM);
@@ -514,8 +570,9 @@ fn writeTag(req: Req, pane: *Pane) Reply {
const pf = &pane.fs;
const had = tagline.curTail(pane).len;
const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
- if (room == 0) return Reply.fail(req.tag, E.NOSPC);
- const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
+ // Whole or not at all: a write that would pass the limit changes nothing.
+ if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}));
+ const take = wholeUtf8(req.data);
// A truncating write drops ONE trailing newline, its whole text's: a
// newline held from a write before goes in once more text follows it.
if (pf.tag_held_newline) {
@@ -538,9 +595,19 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply {
if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text);
const pf = &pane.fs;
if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed);
- clampAddr(pf, bodyOf(pane).len);
+ const held = if (p.fs.batch.serial == pane.serial and p.fs.batch.node == req.node and p.fs.batch.handle == req.handle) p.fs.batch.bytes.items.len else 0;
+ clampAddr(pf, bodyOf(pane).len + held);
const q0: usize = pf.addr.q0;
const q1: usize = @max(q0, @as(usize, pf.addr.q1));
+ // An insert (an empty address) goes on from the open's held bytes.
+ if (q1 == q0) if (batchFor(p, req, pane, q0)) |b| {
+ const take = wholeUtf8(req.data);
+ b.bytes.appendSlice(p.gpa, req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM);
+ b.last_ns = p.now_ns;
+ pf.addr = .{ .q0 = clip(q0 + take), .q1 = clip(q0 + take) };
+ return .{ .tag = req.tag, .written = @intCast(take) };
+ };
+ flushBatch(p);
const before = dotOf(pane);
joinOnly(pane, req.handle);
const take = spliceBody(p, pane, q0, q1, req.data) orelse
@@ -1097,6 +1164,62 @@ test "the writes of one open are one undo step, as bash's line-buffered printf m
}
}
+test "an open's writes in a row are held and go in as one edit, seen by the next request" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle;
+ const revision = p.panes[0].?.file.?.revision;
+ for (0..100) |_| _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "0123456789\n" });
+ // Held: nothing edited yet.
+ try testing.expectEqual(revision, p.panes[0].?.file.?.revision);
+ // Any other request puts them in, once.
+ try testing.expectEqual(@as(usize, 4 + 1100), rd(p, body, 0, 4096).bytes.len);
+ try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = body, .handle = h, .opened = true });
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
+ // data inserts at its address go on the same way.
+ const data = Node.of(serial, .data);
+ _ = wr(p, Node.of(serial, .addr), "#0");
+ const d = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "a" });
+ _ = call(p, .{ .tag = 5, .op = .write, .node = data, .handle = d, .data = "b" });
+ _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = d, .opened = true });
+ try testing.expectEqualStrings("abone\n", rd(p, body, 0, 64).bytes);
+}
+
+test "writes held before the shell's clock is first seen go in at its first step" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const body = Node.of(serialOf(p), .body);
+ try testing.expect(!p.clock_started);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = body, .omode = 1 }).reply.handle;
+ const revision = p.panes[0].?.file.?.revision;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = body, .handle = h, .data = "x\n" });
+ try testing.expectEqual(@as(u64, 0), p.fs.batch.last_ns);
+ // A detached host's first step: its clock is days into the machine's.
+ p.advance(700_000 * std.time.ns_per_s);
+ flushPausedBatch(p);
+ try testing.expectEqual(revision + 1, p.panes[0].?.file.?.revision);
+}
+
+test "a tag write past the limit is refused whole, naming the limit" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const tag = Node.of(serialOf(p), .tag);
+ const before = try testing.allocator.dupe(u8, rd(p, tag, 0, 1 << 16).bytes);
+ defer testing.allocator.free(before);
+ const big = "w" ** (limits.max_tag_tail + 10);
+ const r = wr(p, tag, big);
+ try testing.expectEqual(E.NOSPC, r.errno());
+ // `no space` is what 9ns maps to ENOSPC, so a shell through a mount
+ // sees the errno the tree gives.
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
+ try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
+}
+
test "two opens writing one body are two undo steps, however their writes interleave" {
const p = try withFile(testing.allocator, "one\n");
defer p.deinit();
@@ -1153,13 +1276,19 @@ test "a name cut across writes is one name, applied once at its newline or its c
const node = Node.of(serial, .name);
const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
try testing.expect(h != 0);
+ // A piece that fills its Twrite goes on in the next.
+ p.fs.write_room = "/tmp/pardes-na".len;
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" });
+ p.fs.write_room = 0;
try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
_ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" });
try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
- // No newline: the close applies it.
- _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-closed.txt" });
_ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true });
+ // No newline, the whole write: applied at once, on its own open.
+ const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" });
+ try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true });
try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?));
}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index d32c35fd..8ba9f4df 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -61,6 +61,10 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
+ // A command pane's child is its command, done or running: exec
+ // would run its line again with no header, footer or records.
+ if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pane.command != null)
+ return tree.failText(req.tag, E.INVAL, "invalid exec: a command pane does not restart");
// A shell restarted where the directory is gone would not start:
// refused before anything runs, as Tty there is.
if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) {
@@ -204,6 +208,12 @@ fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void
pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.run;
if (slot.phase == .sent or slot.phase == .waiting) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
+ // One line a run, its answer read before the next: a second line (bash
+ // writes printf 'a\nb\n' a line at a time) would run and lose the
+ // first's answer.
+ const one = std.mem.trimEnd(u8, req.data, "\r\n");
+ if (std.mem.indexOfScalar(u8, one, '\n') != null or (slot.phase == .done and slot.read == 0))
+ return tree.failText(req.tag, E.INVAL, e_one_line);
// A new line on this open: a new answer.
p.gpa.free(slot.output);
p.gpa.free(slot.line);
@@ -421,6 +431,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run {
}
const e_bad_line = "bad command line";
+const e_one_line = "invalid run: one line a run, its answer read before the next";
const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec";
pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply {
@@ -1087,3 +1098,31 @@ test "a run's answer longer than its room is cut, its newline kept" {
try testing.expectEqual(slot.answer.len, slot.len);
try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]);
}
+
+test "exec on a command pane is refused: a command pane does not restart" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const pane = p.panes[p.paneBySerial(serial).?].?;
+ pane.command = try p.gpa.dupe(u8, "echo hi");
+ const r = th.wr(p, tree.Node.of(serial, .pty_ctl), "exec\n");
+ try testing.expectEqual(tree.E.INVAL, r.errno());
+ try testing.expectEqualStrings("invalid exec: a command pane does not restart", r.reply.ename);
+}
+
+test "pty/run takes one line a run: two in one write, or a second before the first's answer is read, are refused" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const run = tree.Node.of(serial, .pty_run);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = run, .omode = 2 }).reply.handle;
+ const two = th.call(p, .{ .tag = 2, .op = .write, .node = run, .handle = h, .data = "true\nfalse\n" });
+ try testing.expectEqual(tree.E.INVAL, two.errno());
+ try testing.expectEqualStrings(e_one_line, two.reply.ename);
+ // A finished run whose answer nobody read takes no next line.
+ const slot = &tree.openOf(p, .{ .tag = 0, .op = .read, .node = run, .handle = h }).?.what.run;
+ slot.phase = .done;
+ slot.len = 7;
+ try testing.expectEqual(tree.E.INVAL, th.call(p, .{ .tag = 3, .op = .write, .node = run, .handle = h, .data = "true\n" }).errno());
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = run, .handle = h, .opened = true });
+}
diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig
index 927552e2..b8eaa02b 100644
--- a/src/ninep/testing.zig
+++ b/src/ninep/testing.zig
@@ -58,11 +58,16 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer {
/// Whether the log, read whole from a fresh open, holds `needle`.
pub fn logHas(p: *Pardes, needle: []const u8) bool {
+ return logCount(p, needle) > 0;
+}
+
+/// How many times the log, read whole from a fresh open, holds `needle`.
+pub fn logCount(p: *Pardes, needle: []const u8) usize {
const log = @intFromEnum(tree.TopFile.log);
const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
- return std.mem.indexOf(u8, text, needle) != null;
+ return std.mem.count(u8, text, needle);
}
pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer {
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index d6f4211b..5f3ffc9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -454,6 +454,86 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
// ---- dispatch ----
pub fn handle(p: *Pardes, req: Req) Reply {
+ var reply = serve(p, req);
+ if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno);
+ if (reply.status == .err and reply.ename.len > cloud9.fs.errmax)
+ if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| {
+ reply.ename = fitErr(reply.ename, room);
+ } else |_| {};
+ return reply;
+}
+
+/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes)
+/// keeps its end, which says why: the longest path in it -- else the whole
+/// text -- gives up its middle to `…`.
+pub fn fitErr(text: []const u8, out: []u8) []const u8 {
+ const ell = "…";
+ if (text.len <= out.len) return text;
+ const over = text.len - out.len + ell.len;
+ var from: usize = 0;
+ var to: usize = text.len;
+ var words = std.mem.tokenizeAny(u8, text, " \t");
+ var longest: usize = 0;
+ while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) {
+ longest = w.len;
+ from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr);
+ to = from + w.len;
+ };
+ var a = from + (to - from - over) / 2;
+ var b = a + over;
+ while (a > from and text[a] & 0xC0 == 0x80) a -= 1;
+ while (b < to and text[b] & 0xC0 == 0x80) b += 1;
+ return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len];
+}
+
+test "a reason past 128 bytes keeps its end: the path in it gives up its middle" {
+ var out: [128]u8 = undefined;
+ const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory";
+ const fit = fitErr(long, &out);
+ try testing.expect(fit.len <= 128);
+ try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/"));
+ try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory"));
+ try testing.expect(std.mem.indexOf(u8, fit, "…") != null);
+ try testing.expect(std.unicode.utf8ValidateSlice(fit));
+ // No path: the whole text's middle goes, the end stays.
+ const words = "why " ** 40 ++ "the reason";
+ try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason"));
+ try testing.expectEqualStrings("short", fitErr("short", &out));
+ // Through the tree: a look at a long ./ name not there says why at the end.
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var line: [512]u8 = undefined;
+ const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20}));
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(r.reply.ename.len <= 128);
+ try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory"));
+}
+
+/// A refusal with no reason of its own said in Plan 9's words, not the C
+/// library's (`Operation not permitted`); each maps back to its errno in
+/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does.
+pub fn errWords(errno: u16) []const u8 {
+ return switch (errno) {
+ E.PERM => "permission denied",
+ E.NOENT => "file does not exist",
+ E.NOMEM => "out of memory",
+ E.BUSY => "file in use",
+ E.EXIST => "file already exists",
+ E.NOTDIR => "not a directory",
+ E.ISDIR => "is a directory",
+ E.INVAL => "bad argument",
+ E.NFILE => "too many open files",
+ E.NOSPC => "no space left",
+ E.NOSYS => "not supported",
+ E.NOTEMPTY => "directory not empty",
+ else => "i/o error",
+ };
+}
+
+fn serve(p: *Pardes, req: Req) Reply {
+ // Held writes go in before anything but the next write of their open.
+ if (p.fs.batch.bytes.items.len > 0 and !(req.op == .write and req.node == p.fs.batch.node and req.handle == p.fs.batch.handle))
+ pane.flushBatch(p);
if (req.op == .open and req.create) {
if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null)
return Reply.fail(req.tag, E.NOENT);
@@ -1036,7 +1116,7 @@ fn linesFile(target: Target) bool {
.top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
.col => |c| c.file == .ctl or c.file == .exec,
// `name` too: one name, however its writes cut it, applied once
- // its newline or its close comes.
+ // its newline comes, or with its write when that is whole.
.pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec or t.file == .name,
};
}
@@ -1073,7 +1153,18 @@ fn write(p: *Pardes, req: Req, target: Target) Reply {
// to fail unseen at the close.
if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control);
o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
- const end = ctl.completeEnd(p, o.pending.items);
+ var end = ctl.completeEnd(p, o.pending.items);
+ // A tail with no newline is a whole line when the write is the whole of
+ // what its client wrote (shorter than a Twrite can carry): it runs now,
+ // and its failure is this write's, as acme takes each write whole. Held
+ // only when it is not whole yet -- an open `{` block, an `a`/`c`/`i`
+ // text awaiting its `.` -- or the write filled its Twrite and may go on.
+ const cut = p.fs.write_room != 0 and req.data.len >= p.fs.write_room;
+ if (end < o.pending.items.len and !cut) {
+ o.pending.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
+ const whole = ctl.completeEnd(p, o.pending.items);
+ if (whole == o.pending.items.len) end = whole else o.pending.shrinkRetainingCapacity(o.pending.items.len - 1);
+ }
if (end == 0) {
if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
o.pending.clearRetainingCapacity();
@@ -1537,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expectEqual(before, p.next_serial);
}
+test "random placements, refused or not, leave no pane under its tag and two rows" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var look_line: [4200]u8 = undefined;
+ const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir});
+ var seed: u64 = 0;
+ while (seed < 300) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const r = prng.random();
+ const rows = r.intRangeAtMost(u16, 6, 40);
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile("x\n");
+ while (p.nextEffect()) |_| {}
+ for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n");
+ if (r.boolean()) p.settings.placement = .pardes;
+ for (0..40) |step| {
+ var serials: [pardes.MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |pn| {
+ serials[n] = pn.serial;
+ n += 1;
+ };
+ if (n == 0) break;
+ const at = serials[r.uintLessThan(usize, n)];
+ const action = r.uintLessThan(u8, 8);
+ switch (action) {
+ 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"),
+ 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }),
+ 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"),
+ 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"),
+ 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"),
+ 5 => _ = th.wr(p, Node.of(at, .look), looked),
+ 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"),
+ else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"),
+ }
+ for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) {
+ std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c });
+ for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) });
+ return error.TestUnexpectedResult;
+ };
+ }
+ }
+}
+
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -1550,8 +1690,8 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
- try testing.expect(th.logHas(p, said));
- // Its err alone says it: no msg besides (the one rule).
+ // A refused open is its error alone: no err, and no msg besides.
+ try testing.expect(!th.logHas(p, said));
try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
@@ -1580,8 +1720,12 @@ test "a command line cut across writes runs once whole, and the last runs at rel
const exec = @intFromEnum(TopFile.exec);
const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
try testing.expect(h != 0);
- for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ // Each piece fills its Twrite (a client's one write cut at msize).
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| {
+ p.fs.write_room = @intCast(piece.len);
try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ }
+ p.fs.write_room = 0;
try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
@@ -1596,6 +1740,60 @@ test "a command line cut across writes runs once whole, and the last runs at rel
try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
}
+test "a refused open, create or remove says why in words and logs no err; a refused write logs one" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ // pane/new is read, never written: a write is refused, and is no
+ // failure the log tells of.
+ const new = th.wr(p, @intFromEnum(TopFile.new), "x\n");
+ try testing.expectEqual(Status.err, new.reply.status);
+ try testing.expectEqualStrings("permission denied", new.reply.ename);
+ const made = call(p, .{ .tag = 2, .op = .open, .node = Node.of(serial, .body), .create = true });
+ try testing.expectEqualStrings("permission denied", made.reply.ename);
+ const gone = th.rmdir(p, @intFromEnum(TopFile.index));
+ try testing.expectEqual(Status.err, gone.reply.status);
+ try testing.expect(gone.reply.ename.len > 0 and !std.ascii.isUpper(gone.reply.ename[0]));
+ // An OTRUNC open's truncation of data after a failed addr: refused, no err.
+ _ = th.wr(p, Node.of(serial, .addr), "/nothere/");
+ const errs = th.logCount(p, "\nerr ");
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }).reply.status);
+ try testing.expectEqual(errs, th.logCount(p, "\nerr "));
+ try testing.expectEqual(@as(usize, 1), errs); // the addr write's own
+ // A write is the rule's: refused, it logs its one err, in words too.
+ try testing.expectEqual(Status.err, th.wr(p, Node.of(serial, .ctl), "bogus\n").reply.status);
+ try testing.expectEqual(@as(usize, 2), th.logCount(p, "\nerr "));
+}
+
+test "a write with no newline, whole in its Twrite, runs then and fails the write; one needing more waits" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ p.fs.write_room = 8192;
+ defer p.fs.write_room = 0;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "bogus" }).reply.status);
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 3, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,s/zzz/y/" }).reply.status);
+ // An `a` needs its text and `.`: held, not run, not failed.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 4, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a" }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = "\nmore\n.\n" }).reply.status);
+ try testing.expectEqualStrings("abc\nmore\n", p.panes[0].?.file.?.content);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ // The root's ctl too.
+ const root_ctl = @intFromEnum(TopFile.ctl);
+ const r = call(p, .{ .tag = 7, .op = .open, .node = root_ctl, .omode = 1 }).reply.handle;
+ try testing.expectEqual(Status.err, call(p, .{ .tag = 8, .op = .write, .node = root_ctl, .handle = r, .data = "bogus" }).reply.status);
+ _ = call(p, .{ .tag = 9, .op = .release, .node = root_ctl, .handle = r, .opened = true });
+ // And a name: a bad one fails its write, not the close after it.
+ const name = Node.of(serial, .name);
+ const n = call(p, .{ .tag = 10, .op = .open, .node = name, .omode = 1 }).reply.handle;
+ const bad = call(p, .{ .tag = 11, .op = .write, .node = name, .handle = n, .data = "delete " });
+ try testing.expectEqual(Status.err, bad.reply.status);
+ try testing.expect(std.mem.indexOf(u8, bad.reply.ename, "a blank at its end") != null);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 12, .op = .release, .node = name, .handle = n, .opened = true }).reply.status);
+}
+
test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index dc479909..22d5f7a1 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4792,6 +4792,7 @@ pub const Pardes = struct {
for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane);
p.saveRecent();
p.recent.deinit(p.recentGpa());
+ p.fs.batch.bytes.deinit(p.gpa);
if (p.stand_in) |pane| p.teardownPane(pane);
look.cancelLookHover(p);
if (p.repl_ask) |ask| p.gpa.free(ask.text);
@@ -5081,6 +5082,15 @@ pub const Pardes = struct {
else
"Newcol: no space for a column: this one is too narrow to split");
};
+ // A narrower column wraps long tags onto more rows, which can leave
+ // a pane under its tag and two rows: its column's rows are shared
+ // out again, and where they cannot hold every minimum there is no
+ // new column, as a size too small is refused.
+ for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) {
+ layout.dropColumn(p, c);
+ layout.compute(p);
+ return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit");
+ };
tagline.enterHeader(p, c);
}
@@ -5686,6 +5696,8 @@ pub const Pardes = struct {
f(h.ctx, timeout);
}
if (v.now) |clock| p.advance(clock(h.ctx));
+ // What a 9P open wrote in a row goes in once the writes pause.
+ ctlfs.pane.flushPausedBatch(p);
while (p.nextQueued()) |ev| p.update(ev);
while (p.nextEffect()) |e| p.perform(e);
turn.settle();
@@ -5716,14 +5728,21 @@ pub const Pardes = struct {
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
- p.needs_frame = true;
+ // An address, a limit or the mark flag is nothing drawn: a script's
+ // addr write then its close costs no frame between them.
+ const drawn = if (ctlfs.Node.target(req.node)) |t| !(t == .pane and (t.pane.file == .addr or t.pane.file == .limit or t.pane.file == .mark)) else true;
+ if (drawn) p.needs_frame = true;
p.raw_hover_intent = false;
look.cancelLookHover(p);
p.fs.serving = true;
p.fs.lsp_answer_at = null;
const reply = ctlfs.handle(p, req);
p.fs.serving = false;
- if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
+ // The one-failure rule is a write's: a refused open (its OTRUNC's
+ // truncation, a setattr, too), create or remove is its Rerror alone,
+ // no err, as is a write to pane/new, which is only ever read.
+ const ruled = req.op == .write and req.node != @intFromEnum(ctlfs.TopFile.new);
+ if (reply.status == .err and ruled) ctlfs.events.noteError(p, req, reply);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -7123,6 +7142,10 @@ pub const Pardes = struct {
/// sleep exactly as long as nothing changes.
pub fn nextWake(p: *const Pardes) ?u64 {
const next = p.stepped_ns + animation.frame_ns;
+ // Held 9P writes go in when they pause: wake for it, never before
+ // the next frame (writes served before the shell's clock was first
+ // seen are stamped 0, which `advance` would count back from).
+ if (p.fs.batch.bytes.items.len > 0) return @max(next, p.fs.batch.last_ns + ctlfs.pane.batch_pause_ns);
if (p.chrome_animation.isActive() or p.presentation.animating()) return next;
var frames: ?u64 = null;
if (p.look_hover_wait) |waiting| {
diff --git a/src/regexp.zig b/src/regexp.zig
index ca396ff1..ddb5eb28 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -27,8 +27,19 @@ const mvzr = @import("mvzr");
/// enough line (`\s*(\w+)\s*=` over 20 KB of letters) runs out of budget. A
/// regex engine of sam's own would lift these; the user chose not to have
/// one.
+/// mvzr's own `Regex` holds 64 operations, some 64 pattern characters; a
+/// search pattern is often longer. Past these a pattern is refused naming
+/// the limit (`e_long`).
+pub const max_ops = 512;
+const Compiled = mvzr.SizedRegex(max_ops, 64);
+
pub const Regex = struct {
- re: mvzr.Regex,
+ re: Compiled,
+ /// The literal every match starts with, when the pattern opens with one
+ /// (`line 049`, `foo` in `foo.*bar`): a search goes straight to where it
+ /// occurs rather than trying mvzr at every line.
+ lit: [32]u8 = undefined,
+ lit_len: u8 = 0,
/// The pattern names a newline: it runs over the whole text.
spans: bool,
/// The pattern starts with `^`: a search begun mid-line skips the line.
@@ -41,19 +52,48 @@ pub const Regex = struct {
/// patterns take 45-80 ms, and a Debug build is ten times slower.
pub const budget: u64 = if (builtin.mode == .Debug) 4_000_000 else 32_000_000;
+ pub const e_long = std.fmt.comptimePrint("bad regular expression: longer than mvzr's {d} operations (about {d} pattern characters)", .{ max_ops, max_ops });
pub const e_anchor = "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n";
+ pub const e_wide = std.fmt.comptimePrint("bad regular expression: a range of runes wider than {d} in [...] is not supported", .{max_range});
+ pub const e_negated = "bad regular expression: a [^...] with non-ASCII runes is not supported (mvzr's classes hold bytes)";
+
+ /// The most runes a non-ASCII range in a class is spelled out as.
+ pub const max_range = 256;
/// `Anchor`: a pattern that names a newline has `^` other than first,
/// or `$` other than just before a `\n`, which mvzr would read as the
/// ends of the whole text and so never match where sam would.
- pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex {
+ /// The plain characters a pattern opens with, each of which every match
+ /// must start with: up to the first metacharacter, less the last one
+ /// when a quantifier makes it optional or repeated.
+ fn literalPrefix(pat: []const u8, out: *[32]u8) u8 {
+ // ponytail: any `|` and there is none (a match may start with
+ // another branch); a top-level-only check would keep `a(b|c)`'s.
+ if (std.mem.indexOfScalar(u8, pat, '|') != null) return 0;
+ var n: usize = 0;
+ while (n < pat.len and n < out.len) : (n += 1) {
+ if (std.mem.indexOfScalar(u8, "\\^$.[]()|*+?{}", pat[n]) != null) break;
+ out[n] = pat[n];
+ }
+ if (n < pat.len and std.mem.indexOfScalar(u8, "*?{", pat[n]) != null) n -|= 1;
+ return @intCast(n);
+ }
+
+ pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes };
+
+ pub fn compile(pat: []const u8) Error!Regex {
if (pat.len == 0) return error.Bad;
+ // mvzr's classes hold bytes: `[éa-z]` is written `(é|[a-z])` for it.
+ // The limits apply to the pattern as rewritten.
+ var runes: [5 * max_ops + 2]u8 = undefined;
+ if (try runeClasses(pat, &runes)) |whole| return compile(whole);
// mvzr takes `^` only at its pattern's start, so `^def|^ ` (a `^`
// after a `|`) is written `^(def| )` for it: the same lines. A mix,
// `^a|b`, has no such spelling and is refused rather than wrong.
- var joined: [258]u8 = undefined;
+ var joined: [5 * max_ops + 2]u8 = undefined;
if (try anchoredAlternation(pat, &joined)) |whole| return compile(whole);
- var buf: [256]u8 = undefined;
+ // `.` may become `[^\n]`: five bytes for one.
+ var buf: [5 * max_ops]u8 = undefined;
var len: usize = 0;
var spans = false;
// Twice over the pattern: the first pass learns whether it names a
@@ -90,22 +130,150 @@ pub const Regex = struct {
return error.Anchor;
}
if (!emit) continue;
- if (len + piece.len > buf.len) return error.Bad;
+ if (len + piece.len > buf.len) return error.TooLong;
@memcpy(buf[len..][0..piece.len], piece);
len += piece.len;
}
}
+ var lit: [32]u8 = undefined;
+ const lit_len = if (spans) 0 else literalPrefix(pat, &lit);
return .{
- .re = mvzr.compile(buf[0..len]) orelse return error.Bad,
+ .lit = lit,
+ .lit_len = lit_len,
+ .re = Compiled.compile(buf[0..len]) orelse {
+ // Too long, or malformed: told apart by trying it with room.
+ if (mvzr.SizedRegex(4 * max_ops, 256).compile(buf[0..len]) != null) return error.TooLong;
+ return error.Bad;
+ },
.spans = spans,
.bol = pat[0] == '^',
};
}
+ /// `pat` with each class that holds a non-ASCII rune written as an
+ /// alternation of its runes and a class of the rest (`[éa-z]` is
+ /// `(é|[a-z])`), a range spelled out rune by rune; null when no class
+ /// holds one.
+ fn runeClasses(pat: []const u8, out: *[5 * max_ops + 2]u8) Error!?[]const u8 {
+ var w = std.Io.Writer.fixed(out);
+ var any = false;
+ var i: usize = 0;
+ while (i < pat.len) : (i += 1) {
+ const c = pat[i];
+ if (c == '\\') {
+ w.writeAll(pat[i..@min(i + 2, pat.len)]) catch return error.TooLong;
+ i += 1;
+ continue;
+ }
+ if (c != '[') {
+ w.writeByte(c) catch return error.TooLong;
+ continue;
+ }
+ // The class's end, as mvzr finds it: the first unescaped `]`.
+ var end = i + 1;
+ while (end < pat.len and pat[end] != ']') : (end += 1) {
+ if (pat[end] == '\\') end += 1;
+ }
+ if (end >= pat.len) return error.Bad;
+ const body = pat[i + 1 .. end];
+ if (for (body) |b| {
+ if (b >= 0x80) break false;
+ } else true) {
+ w.writeAll(pat[i .. end + 1]) catch return error.TooLong;
+ i = end;
+ continue;
+ }
+ if (body[0] == '^') return error.NegatedRunes;
+ any = true;
+ try runeClass(body, &w);
+ i = end;
+ }
+ return if (any) w.buffered() else null;
+ }
+
+ fn runeClass(body: []const u8, w: *std.Io.Writer) Error!void {
+ var ascii: [5 * max_ops]u8 = undefined;
+ var n: usize = 0;
+ w.writeByte('(') catch return error.TooLong;
+ var alts: usize = 0;
+ var j: usize = 0;
+ while (j < body.len) {
+ if (body[j] == '\\') {
+ const len: usize = if (j + 1 < body.len and body[j + 1] == 'x') 4 else 2;
+ if (j + len > body.len) return error.Bad;
+ // An escape at one end of a range whose other end is a rune.
+ if (j + len + 1 < body.len and body[j + len] == '-' and body[j + len + 1] >= 0x80) return error.Bad;
+ if (n + len > ascii.len) return error.TooLong;
+ @memcpy(ascii[n..][0..len], body[j..][0..len]);
+ n += len;
+ j += len;
+ continue;
+ }
+ const lo, const lo_len = try rune(body[j..]);
+ j += lo_len;
+ var hi = lo;
+ if (j + 1 < body.len and body[j] == '-') {
+ if (body[j + 1] == '\\' and lo >= 0x80) return error.Bad;
+ if (body[j + 1] != '\\') {
+ hi, const hi_len = try rune(body[j + 1 ..]);
+ j += 1 + hi_len;
+ }
+ }
+ if (hi < lo) return error.Bad;
+ if (hi - lo + 1 > max_range) return error.WideRange;
+ // Runes that differ only in their last byte go as one
+ // alternative, `\xc3[\xa0-\xbf]` for `[à-ÿ]`: 256 runes one a
+ // time would pass `max_ops`.
+ var run: [4]u8 = undefined;
+ var run_len: usize = 0;
+ var run_hi: u8 = 0;
+ var cp = lo;
+ while (cp <= hi + 1) : (cp += 1) {
+ var enc: [4]u8 = undefined;
+ const len = if (cp > hi) 0 else std.unicode.utf8Encode(cp, &enc) catch continue; // a surrogate
+ if (run_len > 0 and (len != run_len or enc[len - 1] != run_hi + 1 or
+ !std.mem.eql(u8, enc[0 .. len - 1], run[0 .. len - 1])))
+ {
+ if (alts > 0) w.writeByte('|') catch return error.TooLong;
+ w.writeAll(run[0 .. run_len - 1]) catch return error.TooLong;
+ if (run_hi == run[run_len - 1])
+ w.writeByte(run_hi) catch return error.TooLong
+ else
+ w.print("[\\x{x:0>2}-\\x{x:0>2}]", .{ run[run_len - 1], run_hi }) catch return error.TooLong;
+ alts += 1;
+ run_len = 0;
+ }
+ if (cp > hi) break;
+ if (cp < 0x80) {
+ // `\xHH` rather than the byte: `]`, `^`, `-` and `\`
+ // would otherwise mean something in the class.
+ if (n + 4 > ascii.len) return error.TooLong;
+ _ = std.fmt.bufPrint(ascii[n..][0..4], "\\x{x:0>2}", .{cp}) catch unreachable;
+ n += 4;
+ continue;
+ }
+ if (run_len == 0) {
+ run = enc;
+ run_len = len;
+ }
+ run_hi = enc[len - 1];
+ }
+ }
+ if (n > 0) w.print("|[{s}]", .{ascii[0..n]}) catch return error.TooLong;
+ w.writeByte(')') catch return error.TooLong;
+ }
+
+ /// The rune `s` opens with and its length in bytes.
+ fn rune(s: []const u8) error{Bad}!struct { u21, usize } {
+ const len = std.unicode.utf8ByteSequenceLength(s[0]) catch return error.Bad;
+ if (len > s.len) return error.Bad;
+ return .{ std.unicode.utf8Decode(s[0..len]) catch return error.Bad, len };
+ }
+
/// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at
/// its top level and every branch starts with `^`; null when it is not
/// one, or no branch does.
- fn anchoredAlternation(pat: []const u8, out: *[258]u8) error{Bad}!?[]const u8 {
+ fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{Bad}!?[]const u8 {
var bars: [16]usize = undefined;
var n: usize = 0;
var depth: usize = 0;
@@ -183,6 +351,24 @@ pub const Regex = struct {
at = 0;
}
while (start <= hi and start <= last) {
+ // A pattern that opens with a literal matches only where it
+ // occurs: on to the line where it next does.
+ if (rx.lit_len > 0 and !rx.spans) {
+ const q = std.mem.indexOfPos(u8, text[0..hi], start + at, rx.lit[0..rx.lit_len]) orelse return null;
+ if (q > last) return null;
+ if (q > start + at) {
+ const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..q], '\n')) |nl| nl + 1 else 0;
+ if (line_start > start) {
+ start = line_start;
+ at = q - line_start;
+ } else at = q - start;
+ if (rx.bol and at > 0) {
+ start = (std.mem.indexOfScalarPos(u8, text[0..hi], q, '\n') orelse return null) + 1;
+ at = 0;
+ continue;
+ }
+ }
+ }
const end = if (rx.spans) hi else std.mem.indexOfScalarPos(u8, text[0..hi], start, '\n') orelse hi;
const line = text[start..end];
// matchPos finds nothing at a haystack's very end, where `$` or
@@ -240,6 +426,56 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li
try std.testing.expectError(error.Bad, Regex.compile("a\\"));
}
+test "a pattern opening with a literal finds what a search from each line finds" {
+ const text = "alpha beta\nbeta gamma\ngamma alpha\nfoo line 049999 x\n";
+ for ([_][]const u8{ "beta", "gam+a", "line 049999", "alph?a", "a.*a", "^gamma", "o+", "h$|zz", "beta|x" }) |pat| {
+ var fast = try Regex.compile(pat);
+ var slow = try Regex.compile(pat);
+ slow.lit_len = 0;
+ var from: usize = 0;
+ while (from < text.len) : (from += 1) {
+ const a = try fast.find(text, from, text.len, text.len);
+ const b = try slow.find(text, from, text.len, text.len);
+ try std.testing.expectEqual(b == null, a == null);
+ if (a) |m| try std.testing.expectEqual(b.?.start, m.start);
+ }
+ }
+}
+
+test "a class with non-ASCII runes matches those runes: [éa-z], a range of them, and refuses a wide range or [^é]" {
+ const text = "1 é 2 b 3 ü 4 ñ\n";
+ var mixed = try Regex.compile("[éa-z]+");
+ try std.testing.expectEqual(@as(usize, 2), (try mixed.find(text, 0, text.len, text.len)).?.start);
+ const m = (try mixed.find(text, 5, text.len, text.len)).?;
+ try std.testing.expectEqualStrings("b", text[m.start..m.end]);
+ var range = try Regex.compile("3 [à-ÿ]");
+ const r = (try range.find(text, 0, text.len, text.len)).?;
+ try std.testing.expectEqualStrings("3 ü", text[r.start..r.end]);
+ // An ASCII member that means something in a class stays a member.
+ var odd = try Regex.compile("[ñ\\]^-]");
+ try std.testing.expectEqual(@as(usize, 16), (try odd.find(text, 0, text.len, text.len)).?.start);
+ var none = try Regex.compile("[ö]");
+ try std.testing.expect(try none.find(text, 0, text.len, text.len) == null);
+ try std.testing.expectError(error.WideRange, Regex.compile("[ā-ӿ]"));
+ try std.testing.expectError(error.NegatedRunes, Regex.compile("[^é]"));
+ try std.testing.expectError(error.Bad, Regex.compile("[é"));
+ try std.testing.expectError(error.Bad, Regex.compile("[ÿ-à]"));
+ // A full 256-rune range fits the limit as rewritten.
+ var wide = try Regex.compile("x[Ā-ǿ]");
+ try std.testing.expectEqual(@as(usize, 2), (try wide.find("ǿxǿ", 0, 5, 5)).?.start);
+ var kana = try Regex.compile("[ぁ-ゟ]"); // 3 bytes, across a last-byte wrap
+ try std.testing.expectEqual(@as(usize, 1), (try kana.find("aゞ", 0, 4, 4)).?.start);
+ try std.testing.expectError(error.WideRange, Regex.compile("[Ā-Ȁ]"));
+}
+
+test "a pattern past 64 characters compiles, and one past the limit says so" {
+ var long = try Regex.compile("a" ** 200);
+ const text = "x" ++ "a" ** 200 ++ "\n";
+ try std.testing.expectEqual(@as(usize, 1), (try long.find(text, 0, text.len, text.len)).?.start);
+ try std.testing.expectError(error.TooLong, Regex.compile("a" ** (max_ops + 8)));
+ try std.testing.expectError(error.Bad, Regex.compile("a[b"));
+}
+
test "a ^ after | anchors that branch: ^def|^ finds a line that starts either way, and a mix is refused" {
const text = "x def\n a\ndef b\n";
var both = try Regex.compile("^def|^ ");
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index a88728aa..0f317a83 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -111,11 +111,26 @@ pub fn waitsFor(arena: std.mem.Allocator, command: []const u8) Wait {
var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
while (true) {
const c = ps.parse(0) catch
- return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'")) .group else .none;
+ return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'") or groupOpen(command)) .group else .none;
if (c == null) return if (ps.open_text) .text else .none;
}
}
+/// A `{` block not closed yet by its `}` line: a command in it that fails
+/// to parse still leaves the block to end at its `}`, so the error is that
+/// command's, not the `}`'s taken as a line of its own.
+fn groupOpen(command: []const u8) bool {
+ var depth: usize = 0;
+ var lines = std.mem.splitScalar(u8, command, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (std.mem.eql(u8, line, "}")) {
+ depth -|= 1;
+ } else if (std.mem.endsWith(u8, line, "{")) depth += 1;
+ }
+ return depth > 0;
+}
+
pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
var why: Why = .{};
const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
@@ -445,6 +460,9 @@ const Exec = struct {
fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex {
return regexp.Regex.compile(pat) catch |err| switch (err) {
error.Anchor => fail(ex.why, "{s}", .{regexp.Regex.e_anchor}),
+ error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}),
+ error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}),
+ error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}),
error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
};
}
@@ -455,6 +473,9 @@ const Exec = struct {
/// reaches back into what the last one took.
fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
if (from > hi) return null;
+ // The budget is each search's own: an x over 100k lines makes 100k
+ // searches, none of which is the slow one.
+ rx.steps = regexp.Regex.budget;
const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null;
var start = modal.runeStart(ex.text, m.start);
if (start < from) start = modal.runeEnd(ex.text, m.start);
@@ -693,6 +714,20 @@ fn expectEdit(text: []const u8, command: []const u8, want: []const u8) !void {
try std.testing.expectEqualStrings(want, got);
}
+test "an x over 100k lines is 100k searches, each with its own step budget" {
+ const th = @import("ninep/testing.zig");
+ const line = "x" ** 80 ++ "\n";
+ const text = try std.testing.allocator.alloc(u8, line.len * 100_000);
+ defer std.testing.allocator.free(text);
+ for (0..100_000) |k| @memcpy(text[line.len * k ..][0..line.len], line);
+ const p = try th.withFile(std.testing.allocator, text);
+ defer p.deinit();
+ const tree = @import("ninep/tree.zig");
+ const r = th.wr(p, tree.Node.of(th.serialOf(p), .ctl), "Edit ,x/x+/c/z/\n");
+ try std.testing.expectEqual(tree.Status.ok, r.reply.status);
+ try std.testing.expect(std.mem.startsWith(u8, p.panes[0].?.file.?.content, "z\nz\n"));
+}
+
test "sam's classic commands" {
try expectEdit("foo x foo y foo\n", ",x/foo/c/foobar/", "foobar x foobar y foobar\n");
try expectEdit("a b\nc d\n", ",x/ /c/_/", "a_b\nc_d\n");
diff --git a/test/fs.py b/test/fs.py
index 3c0ece79..36ba180d 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -622,11 +622,11 @@ def workflow_canary(binary):
rows = client.read(f'/pane/{grep}/body').decode()
assert 'a.txt:2' in rows and 'b.txt:2' in rows, rows
client.remove(f'/pane/{grep}')
- execute_refused(client, a, 'Grep zzqqnomatch', 'grep: pattern not found')
+ execute_refused(client, a, 'Grep zzqqnomatch', 'Grep: pattern not found')
found = made_by(a, b'Find b.txt\n')
assert 'b.txt' in client.read(f'/pane/{found}/body').decode()
client.remove(f'/pane/{found}')
- execute_refused(client, a, 'Find zzqqnomatch', 'find: name not found')
+ execute_refused(client, a, 'Find zzqqnomatch', 'Find: name not found')
# A plain word looked at is found in the pane, after its dot.
client.write(f'/pane/{a}/dot', b' 0 0 ')
@@ -953,6 +953,20 @@ def test(binary, quic=False):
with Client(address, msize=256) as small:
assert set(small.list('/os' + str(listing))) == {
f'entry-{number:02}' for number in range(24)}
+ # 64 KiB frames: a ctl line with no newline, whole in its one
+ # Twrite, runs with it however long -- the cutoff for a write
+ # that may go on is the frame the client asked for, less 24.
+ with Client(address, msize=65536) as big:
+ assert big.msize == 65536
+ ctl = big.open('/pane/1/ctl', 1)
+ line = b'bogus' + b'x' * 20000
+ try:
+ big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ except OSError:
+ pass # refused with its write (a line over 1024 bytes), not held
+ else:
+ raise AssertionError('a whole 20000-byte line waited for a newline')
+ big.close(ctl)
frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
@@ -1224,6 +1238,79 @@ def test(binary, quic=False):
cell[0] for cell in local.screen()['cells'])
assert client.read('/index') == before
+ # --wait (acme's E): a launch inside a pane lasts until the pane it
+ # opened is deleted, as an $EDITOR must; one on a file already open
+ # waits on that pane; a session that goes away ends it with 1.
+ with session(binary, root, 'wait') as (client, address):
+ status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines())
+ env = {k: v for k, v in os.environ.items() if k != 'PARDES_FORWARD_LOOK'}
+ env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid'])
+ edited = root / 'wait edit.txt'
+ edited.write_bytes(b'edit me\n')
+
+ def serials():
+ return {int(row.split()[0]) for row in client.read('/index').splitlines()}
+
+ def launch(flag):
+ before = serials()
+ child = subprocess.Popen([binary, flag, str(edited)], cwd=root, env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ deadline = time.monotonic() + 3
+ while child.poll() is None and not serials() - before and time.monotonic() < deadline:
+ time.sleep(.01)
+ return child
+
+ first = launch('--wait')
+ opened, = serials() - {1}
+ second = launch('-w')
+ time.sleep(.5)
+ assert first.poll() is None and second.poll() is None, (first.stderr.read(), second.stderr.read())
+ assert serials() == {1, opened}
+ client.write(f'/pane/{opened}/body', b'edited\n', truncate=True)
+ execute(client, opened, 'Save')
+ client.remove(f'/pane/{opened}')
+ deleted = time.monotonic()
+ assert first.wait(timeout=5) == 0
+ # Woken by the `del` record, not a poll.
+ assert time.monotonic() - deleted < .05, time.monotonic() - deleted
+ assert second.wait(timeout=5) == 0
+ assert edited.read_bytes() == b'edited\n'
+ # A name not there yet opens a pane named it, as acme's B does,
+ # and Save creates it: git's and fish's temporary files are.
+ fresh = root / 'wait new.txt'
+ before = serials()
+ made = subprocess.Popen([binary, '--wait', fresh.name], cwd=root, env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ deadline = time.monotonic() + 3
+ while not serials() - before:
+ assert made.poll() is None, made.stderr.read()
+ assert time.monotonic() < deadline, 'a new name opened no pane'
+ time.sleep(.01)
+ named, = serials() - before
+ assert str(fresh).encode() in client.read('/index')
+ client.write(f'/pane/{named}/body', b'new\n')
+ execute(client, named, 'Save')
+ client.remove(f'/pane/{named}')
+ assert made.wait(timeout=5) == 0
+ assert fresh.read_bytes() == b'new\n'
+ orphan = launch('--wait')
+ assert orphan.poll() is None
+ assert orphan.wait(timeout=5) == 1
+
+ # A launch with no terminal to draw on (a pty that is no one's
+ # controlling terminal) says so and exits 1, no error trace.
+ master, slave = os.openpty()
+ try:
+ env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color')
+ result = subprocess.run([binary, '--tty', 'x.txt'], cwd=root, env=env, stdin=slave, stdout=slave,
+ stderr=subprocess.PIPE, start_new_session=True, timeout=10)
+ finally:
+ os.close(master)
+ os.close(slave)
+ assert result.returncode == 1, result
+ assert b'pardes: no terminal to draw on' in result.stderr and b'.zig:' not in result.stderr, result.stderr
+
for tty in [False, True]:
name = 'lsp-tty' if tty else 'lsp-detached'
with session(binary, root, name, tty=tty) as (client, _):
diff --git a/test/gui_monkey.py b/test/gui_monkey.py
new file mode 100644
index 00000000..2b32f050
--- /dev/null
+++ b/test/gui_monkey.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden
+test window gets a random burst of clicks, drags, wheels, keys, new and
+joined columns, opened files and theme changes, then must settle (its
+frames stop changing) with no crash record and its process alive.
+
+gui_monkey.py <pardes-gui> [outdir] [--seed N] [--steps N]; `zig build
+monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built
+window. Exits 1 when any combination failed; what failed is in outdir.
+"""
+import argparse
+import hashlib
+import itertools
+import os
+import random
+import shutil
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from fs import session # noqa: E402
+
+MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful']
+LIFTS = ['off', 'shadow', 'rim', 'auto']
+BLOOMS = ['0', '2']
+THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak']
+W, H = 1280, 840
+
+
+def settled(cap, timeout=20):
+ latest = cap / 'latest.ppm'
+ previous = None
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ time.sleep(.4)
+ try:
+ data = latest.read_bytes()
+ except FileNotFoundError:
+ continue
+ digest = hashlib.sha256(data).hexdigest()
+ if digest == previous:
+ return True
+ previous = digest
+ return False
+
+
+def run(binary, out, motion, lift, bloom, seed, steps):
+ rng = random.Random(seed)
+ root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-'))
+ cap = root / 'cap'
+ cap.mkdir()
+ (root / 'config/pardes').mkdir(parents=True)
+ (root / 'config/pardes/init').write_text(
+ f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n')
+ src = root / 'a.zig'
+ src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200)))
+ for name in ('b.txt', 'c.txt'):
+ (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80)))
+ display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0')
+ env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0',
+ 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)}
+ name = 'monkey'
+ launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)]
+ handles = {}
+ verdict = 'ok'
+ log = []
+ try:
+ with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _):
+ fd = handles['input_fd']
+ time.sleep(1.0)
+
+ def mouse(kind, button, x, y):
+ os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode())
+
+ def first():
+ rows = client.read('/index').decode().splitlines()
+ return int(rows[0].split()[0]) if rows else None
+
+ for _ in range(steps):
+ x, y = rng.randrange(W), rng.randrange(H)
+ action = rng.randrange(12)
+ try:
+ if action in (0, 1):
+ mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}')
+ elif action == 2:
+ x2, y2 = rng.randrange(W), rng.randrange(H)
+ mouse('down', 1, x, y)
+ for i in range(1, 6):
+ mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5)
+ mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}')
+ elif action == 3:
+ amount = rng.choice([-6, -2, 2, 6])
+ os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}')
+ elif action == 4:
+ mouse('motion', 0, x, y); log.append(f'hover {x},{y}')
+ elif action == 5:
+ client.write('/ctl', b'Newcol\n'); log.append('Newcol')
+ elif action == 6:
+ serial = first()
+ if serial is not None:
+ target = root / rng.choice(['b.txt', 'c.txt', 'a.zig'])
+ client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}')
+ elif action == 7:
+ serial = first()
+ if serial is not None:
+ # Delcol too: the last column's going leaves an
+ # empty window now (acme's), not a quit.
+ word = rng.choice(['Joincol', 'Collapse', 'Delcol'])
+ try:
+ client.write(f'/pane/{serial}/exec', (word + '\n').encode())
+ except OSError:
+ pass
+ log.append(word)
+ else:
+ # An empty window: pane/new makes its column and a pane.
+ try:
+ client.read('/pane/new')
+ except OSError:
+ pass
+ log.append('pane/new (empty window)')
+ elif action == 8:
+ os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b']))
+ log.append('keys')
+ elif action == 9:
+ theme = rng.choice(THEMES)
+ client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}')
+ elif action == 10:
+ mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}')
+ else:
+ time.sleep(rng.choice([0, .05, .2])); log.append('pause')
+ except OSError as why:
+ log.append(f'refused: {why}')
+ time.sleep(.03)
+ if not settled(cap):
+ verdict = 'never settled'
+ try:
+ os.kill(handles['pid'], 0)
+ except OSError:
+ verdict = 'process gone'
+ client.read('/index')
+ except Exception as why: # noqa: BLE001
+ verdict = f'failed: {type(why).__name__}: {str(why)[:300]}'
+ crashes = list((root / 'config').rglob('*crash*'))
+ if crashes:
+ verdict = 'crash record: ' + ', '.join(str(c) for c in crashes)
+ label = f'{motion}-{lift}-bloom{bloom}'
+ if verdict != 'ok':
+ (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n')
+ for c in crashes:
+ shutil.copy(c, out / f'{label}-{c.name}')
+ if (cap / 'latest.ppm').exists():
+ shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm')
+ shutil.rmtree(root, ignore_errors=True)
+ return label, verdict
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('binary', type=Path)
+ ap.add_argument('out', type=Path, nargs='?')
+ ap.add_argument('--seed', type=int, default=1)
+ ap.add_argument('--steps', type=int, default=60)
+ args = ap.parse_args()
+ if args.out is None:
+ args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-'))
+ args.out.mkdir(parents=True, exist_ok=True)
+ bad = 0
+ for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)):
+ label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps)
+ print(f'{label}: {verdict}', flush=True)
+ bad += verdict != 'ok'
+ print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True)
+ sys.exit(1 if bad else 0)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/monkey9p.py b/test/monkey9p.py
new file mode 100644
index 00000000..ee0b0ebc
--- /dev/null
+++ b/test/monkey9p.py
@@ -0,0 +1,1993 @@
+#!/usr/bin/env python3
+"""pardes 9P monkey: random 9P operations against a throwaway detached session.
+
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- [--seed N] [--steps M]
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --replay FILE
+ zig build monkey-9p -Dplatform=tty --prefix <scratch> -- --shrink FILE
+ python3 test/monkey9p.py <pardes> [same flags]
+
+A run starts `pardes --detach` with its own HOME, XDG dirs and a short
+runtime dir in /tmp, speaks raw 9P to it (test/ninep.py), and for each
+step asks a GENERATOR for one concrete operation, runs it, then runs every
+INVARIANT. Everything random comes from --seed; every operation it ran is
+written to <out>/runs/seed-N/ops.jsonl, concrete but for pane and column
+references ({p3}: the 4th serial of /index, mod its length, when the op
+runs, so a shrunk sequence still names live panes), and `--replay` runs
+the same requests again without the generator. A failure writes a bug record
+(seed, step, the replayable operations since that session began) to
+<out>/bugs/, shrinks it by delta debugging over replays unless
+--no-shrink, and the run carries on in a fresh session.
+
+Plug-in points: a generator is a function `(ctx, rng) -> op dict` under
+@generator(weight); an op kind is a function `(sess, op) -> Result` under
+@op_kind(name); an invariant is a function `(ctx, res) -> str | None`
+under @invariant, returning why it failed.
+
+Safety: the session never sees PARDES_*, NINE_MOUNT or NAMESPACE; every
+payload that could reach a shell is drawn from an alphabet with no path
+or shell metacharacters (shell_safe), Edit text from a grammar without
+sam's w/e/r/f/b/B/D/n/!/</>/| commands, and nothing is ever written,
+created or removed under /os or /src (the host filesystem). This process
+makes itself a child subreaper, so every shell the session starts is its
+descendant even after pardes dies, and teardown kills them all.
+"""
+import argparse
+import collections
+import ctypes
+import hashlib
+import json
+import os
+import random
+import re
+import select
+import shutil
+import signal
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from ninep import Client, string # noqa: E402
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEFAULT_OUT = os.path.join(os.path.dirname(REPO), '.scratch', 'monkey9p')
+
+# 9P message types.
+TWALK, TOPEN, TCREATE, TREAD, TWRITE, TCLUNK, TREMOVE, TSTAT, TFLUSH = 110, 112, 114, 116, 118, 120, 122, 124, 108
+RERROR = 107
+OREAD, OWRITE, ORDWR, OTRUNC = 0, 1, 2, 16
+DMDIR = 0x80000000
+
+TIMEOUT = 5.0 # every reply, unless the op is a documented slow one
+SLOW_TIMEOUT = 30.0 # Grep, Find, Dump, Restore, Tty, huge writes
+HELD_WAIT = 0.1 # how long a read of a held file is let wait before Tflush
+MSIZE = 65536 + 24
+
+
+class Hang(Exception):
+ """A 9P reply did not come within its timeout."""
+
+
+class Dropped(Exception):
+ """The server closed the connection (or reset it)."""
+
+
+class Ended(Exception):
+ """pardes quit cleanly: exit 0, no crash file. Closing the session's last
+ pane quits it (fs.md: 'Closing the session's last pane quits pardes'),
+ and a terminal whose shell exits closes its pane, so this can follow any
+ step; the run starts a fresh session and carries on."""
+
+
+# --------------------------------------------------------------------------
+# Wire: raw 9P with per-request timeouts and Tflush for held reads.
+
+class Wire(Client):
+ def __init__(self, address, msize=MSIZE):
+ super().__init__(address, timeout=TIMEOUT, msize=msize)
+ self.next_fid = 100
+
+ def newfid(self):
+ self.next_fid += 1
+ return self.next_fid
+
+ def _send(self, kind, payload):
+ self.tag = (self.tag + 1) % 65535
+ packet = struct.pack('<IBH', 7 + len(payload), kind, self.tag) + payload
+ if len(packet) > self.msize:
+ raise ValueError(f'harness bug: a {len(packet)}-byte request exceeds msize {self.msize}')
+ try:
+ self.socket.sendall(packet)
+ except socket.timeout:
+ raise Hang('send did not drain within the timeout')
+ except OSError as why:
+ raise Dropped(f'send: {why}')
+ return self.tag
+
+ def _recv(self, timeout):
+ self.socket.settimeout(timeout)
+ try:
+ size, kind, tag = struct.unpack('<IBH', self.receive(7))
+ body = self.receive(size - 7)
+ except socket.timeout:
+ raise Hang(f'no reply within {timeout:g}s')
+ except (EOFError, ConnectionResetError, BrokenPipeError) as why:
+ raise Dropped(str(why))
+ except OSError as why:
+ if isinstance(why, socket.timeout):
+ raise Hang(f'no reply within {timeout:g}s')
+ raise Dropped(str(why))
+ return kind, tag, body
+
+ def call(self, kind, payload, timeout=TIMEOUT):
+ """(ok, body_or_error_text)."""
+ tag = self._send(kind, payload)
+ while True:
+ rkind, rtag, body = self._recv(timeout)
+ if rtag != tag:
+ continue # a late reply to a flushed request
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ return False, body[2:2 + n].decode(errors='replace')
+ if rkind != kind + 1:
+ raise Dropped(f'expected reply {kind + 1}, received {rkind}')
+ return True, body
+
+ def call_held(self, kind, payload, wait=HELD_WAIT):
+ """A request the server may hold: after `wait`, flush it. Returns
+ (ok, body) for an answer, or (None, 'flushed')."""
+ tag = self._send(kind, payload)
+ self.socket.settimeout(None)
+ ready, _, _ = select.select([self.socket], [], [], wait)
+ if ready:
+ rkind, rtag, body = self._recv(TIMEOUT)
+ if rtag == tag:
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ return False, body[2:2 + n].decode(errors='replace')
+ return True, body
+ ftag = self._send(TFLUSH, struct.pack('<H', tag))
+ answer = (None, 'flushed')
+ while True:
+ rkind, rtag, body = self._recv(TIMEOUT)
+ if rtag == tag:
+ if rkind == RERROR:
+ n = struct.unpack_from('<H', body)[0]
+ answer = (False, body[2:2 + n].decode(errors='replace'))
+ else:
+ answer = (True, body)
+ elif rtag == ftag:
+ return answer
+
+ # Convenience over call().
+ def walk_names(self, names, timeout=TIMEOUT):
+ """(fid or None, error)."""
+ fid = self.newfid()
+ old = 1
+ if not names:
+ ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, 0), timeout)
+ return (fid, None) if ok else (None, body)
+ for start in range(0, len(names), 16):
+ part = names[start:start + 16]
+ ok, body = self.call(TWALK, struct.pack('<IIH', old, fid, len(part)) +
+ b''.join(string(n) for n in part), timeout)
+ if not ok:
+ if old != 1:
+ self.call(TCLUNK, struct.pack('<I', fid))
+ return None, body
+ if struct.unpack_from('<H', body)[0] != len(part):
+ if old != 1:
+ self.call(TCLUNK, struct.pack('<I', fid))
+ return None, 'walk stopped short'
+ old = fid
+ return fid, None
+
+ def clunk(self, fid):
+ return self.call(TCLUNK, struct.pack('<I', fid))
+
+ def read_all(self, fid, count=None, limit=1 << 22):
+ count = min(count or (self.msize - 11), self.msize - 11)
+ data = bytearray()
+ while len(data) < limit:
+ ok, body = self.call(TREAD, struct.pack('<IQI', fid, len(data), count))
+ if not ok:
+ raise OSError(body)
+ chunk = body[4:]
+ if not chunk:
+ break
+ data.extend(chunk)
+ return bytes(data)
+
+ def read_path(self, path):
+ fid, err = self.walk_names(split(path))
+ if fid is None:
+ raise FileNotFoundError(f'{path}: {err}')
+ try:
+ ok, body = self.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ if not ok:
+ raise OSError(f'{path}: {body}')
+ return self.read_all(fid)
+ finally:
+ self.clunk(fid)
+
+ def write_path(self, path, data):
+ """(ok, error) for one open, one write, one clunk."""
+ fid, err = self.walk_names(split(path))
+ if fid is None:
+ return False, err
+ try:
+ ok, body = self.call(TOPEN, struct.pack('<IB', fid, OWRITE))
+ if not ok:
+ return False, body
+ ok, body = self.call(TWRITE, struct.pack('<IQI', fid, 0, len(data)) + data)
+ return (True, None) if ok else (False, body)
+ finally:
+ self.clunk(fid)
+
+
+def split(path):
+ return [p.encode('latin-1') if isinstance(p, str) else p for p in path.split('/') if p]
+
+
+def dir_names(data):
+ names, offset = [], 0
+ while offset + 2 <= len(data):
+ size = struct.unpack_from('<H', data, offset)[0]
+ n = struct.unpack_from('<H', data, offset + 41)[0]
+ names.append(data[offset + 43:offset + 43 + n].decode('latin-1'))
+ offset += size + 2
+ return names
+
+
+# --------------------------------------------------------------------------
+# Session lifecycle.
+
+def _libc():
+ try:
+ return ctypes.CDLL(None, use_errno=True)
+ except OSError:
+ return None
+
+
+def become_subreaper():
+ """Orphaned shells re-parent to us, not init, so teardown finds them."""
+ libc = _libc()
+ if libc is not None:
+ libc.prctl(36, 1, 0, 0, 0) # PR_SET_CHILD_SUBREAPER
+
+
+def descendants(root):
+ parent = {}
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit():
+ continue
+ try:
+ with open(f'/proc/{entry}/stat', 'rb') as f:
+ stat = f.read()
+ ppid = int(stat[stat.rindex(b')') + 2:].split()[1])
+ except (OSError, ValueError):
+ continue
+ parent.setdefault(ppid, []).append(int(entry))
+ out, stack = [], [root]
+ while stack:
+ for child in parent.get(stack.pop(), []):
+ out.append(child)
+ stack.append(child)
+ return out
+
+
+def tokened(token):
+ """Our own processes whose environment carries this session's token."""
+ found, needle = [], f'M9P_TOKEN={token}'.encode()
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit():
+ continue
+ try:
+ with open(f'/proc/{entry}/environ', 'rb') as f:
+ if needle in f.read().split(b'\0'):
+ found.append(int(entry))
+ except OSError:
+ continue
+ return found
+
+
+def sweep(prefix):
+ """Kill every process whose M9P_TOKEN starts with `prefix`, until none."""
+ needle = f'M9P_TOKEN={prefix}'.encode()
+ for _ in range(100):
+ found = []
+ for entry in os.listdir('/proc'):
+ if not entry.isdigit() or int(entry) == os.getpid():
+ continue
+ try:
+ with open(f'/proc/{entry}/environ', 'rb') as f:
+ if any(v.startswith(needle) for v in f.read().split(b'\0')):
+ found.append(int(entry))
+ except OSError:
+ continue
+ if not found:
+ return
+ for pid in found:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ reap_zombies()
+ time.sleep(0.02)
+
+
+def reap_zombies():
+ while True:
+ try:
+ pid, _ = os.waitpid(-1, os.WNOHANG)
+ except ChildProcessError:
+ return
+ if pid == 0:
+ return
+
+
+RUNTIME_DIRS = [] # removed at exit, whatever ended the run
+
+
+class Session:
+ """One throwaway `pardes --detach`, its dirs, its connection(s)."""
+
+ counter = 0
+
+ def __init__(self, binary, run_dir, follower):
+ Session.counter += 1
+ self.binary = binary
+ self.follower_mode = follower
+ self.token = f'{os.getpid()}-{Session.counter}-{random.SystemRandom().randrange(1 << 30)}'
+ self.dir = os.path.join(run_dir, f'sess{Session.counter}')
+ self.home = os.path.join(self.dir, 'home')
+ self.runtime = tempfile.mkdtemp(prefix='m9p.', dir='/tmp')
+ RUNTIME_DIRS.append(self.runtime)
+ self.proc = None
+ self.wire = None
+ self.follower = None
+ self.handles = {}
+ self.dump_written = False
+ self.marker = None
+ self.window_base = 0
+ self.start()
+
+ def env(self):
+ env = {k: v for k, v in os.environ.items()
+ if not k.startswith('PARDES_') and k not in ('NINE_MOUNT', 'NAMESPACE')}
+ env.update(HOME=self.home, XDG_RUNTIME_DIR=self.runtime,
+ XDG_CONFIG_HOME=os.path.join(self.home, 'config'),
+ XDG_STATE_HOME=os.path.join(self.home, 'state'),
+ XDG_DATA_HOME=os.path.join(self.home, 'data'),
+ XDG_CACHE_HOME=os.path.join(self.home, 'cache'),
+ SHELL='/bin/sh', PARDES_NOTIME='1', M9P_TOKEN=self.token,
+ PARDES_DUMP=os.path.join(self.home, 'm9p.dump.zon'))
+ for language in ['RS', 'C', 'GO', 'TS', 'PY', 'ZIG']:
+ env['PARDES_LSP_' + language] = ''
+ return env
+
+ def start(self):
+ os.makedirs(self.home, exist_ok=True)
+ for name, text in FIXTURES.items():
+ with open(os.path.join(self.home, name), 'wb') as f:
+ f.write(text)
+ self.stderr = open(os.path.join(self.dir, 'stderr'), 'wb')
+ self.proc = subprocess.Popen([self.binary, '--detach=m9p', 'alpha.txt'], cwd=self.home,
+ env=self.env(), stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=self.stderr,
+ start_new_session=True)
+ self.address = os.path.join(self.runtime, 'pardes-9p-m9p.sock')
+ self.connect(first=True)
+ # Two more panes, so one close does not end the session.
+ for name in (b'utf8.txt\n', b'crlf.txt\n'):
+ self.wire.write_path('/look', name)
+
+ def connect(self, first=False):
+ deadline = time.monotonic() + (15 if first else 10)
+ last = None
+ while time.monotonic() < deadline:
+ if self.proc.poll() is not None:
+ raise Dropped(f'pardes exited {self.proc.returncode} before its socket answered')
+ if os.path.exists(self.address):
+ try:
+ self.wire = Wire(self.address)
+ break
+ except OSError as why:
+ last = why
+ time.sleep(0.01)
+ else:
+ raise Hang(f'9P socket did not answer: {last}')
+ self.handles = {}
+ self.marker = None
+ if self.follower_mode:
+ self.follower = Follower(self.address)
+
+ def reconnect(self):
+ self.close_wires()
+ # A Restore hangs up and serves again: let the old socket go.
+ time.sleep(0.05)
+ self.connect()
+
+ def close_wires(self):
+ if self.follower is not None:
+ self.follower.stop()
+ self.follower = None
+ if self.wire is not None:
+ try:
+ self.wire.socket.close()
+ except OSError:
+ pass
+ self.wire = None
+
+ def alive(self):
+ return self.proc is not None and self.proc.poll() is None
+
+ def crash_text(self):
+ """The crash file's text (src/crash.zig) and stderr's tail."""
+ out = []
+ for base, _, files in os.walk(self.home):
+ if 'crashes' in files:
+ with open(os.path.join(base, 'crashes'), 'rb') as f:
+ out.append(f.read().decode(errors='replace'))
+ try:
+ self.stderr.flush()
+ with open(os.path.join(self.dir, 'stderr'), 'rb') as f:
+ text = f.read()
+ # The panic line leads; the frames under it can run long.
+ tail = (text if len(text) <= 6000 else text[:3000] + b'\n...\n' + text[-2000:]).decode(errors='replace')
+ if tail.strip():
+ out.append('stderr: ' + tail)
+ except OSError:
+ pass
+ return '\n'.join(out)
+
+ def has_crash_file(self):
+ for base, _, files in os.walk(os.path.join(self.home, 'config')):
+ if 'crashes' in files:
+ return True
+ return False
+
+ def close(self, keep=False):
+ """Kill pardes and everything it started; verify nothing survives."""
+ self.close_wires()
+ if self.proc is not None and self.proc.poll() is None:
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ self.proc.kill()
+ self.proc.wait()
+ survivors = []
+ for _ in range(100):
+ reap_zombies()
+ pids = set(tokened(self.token)) | set(descendants(os.getpid()))
+ pids.discard(os.getpid())
+ if not pids:
+ break
+ for pid in pids:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ survivors = pids
+ time.sleep(0.02)
+ else:
+ raise RuntimeError(f'processes survived teardown: {sorted(survivors)}')
+ reap_zombies()
+ self.stderr.close()
+ shutil.rmtree(self.runtime, ignore_errors=True)
+ if not keep:
+ shutil.rmtree(self.dir, ignore_errors=True)
+
+
+class Follower:
+ """A second connection holding `follow new` on /log, reading records."""
+
+ def __init__(self, address):
+ self.lines = []
+ self.cond = threading.Condition()
+ self.done = False
+ self.wire = Wire(address)
+ fid, err = self.wire.walk_names([b'log'])
+ ok, body = self.wire.call(TOPEN, struct.pack('<IB', fid, ORDWR))
+ ok, body = self.wire.call(TWRITE, struct.pack('<IQI', fid, 0, 11) + b'follow new\n')
+ if not ok:
+ raise OSError(f'follow new: {body}')
+ self.fid = fid
+ self.thread = threading.Thread(target=self.run, daemon=True)
+ self.thread.start()
+
+ def run(self):
+ offset = 0
+ try:
+ while not self.done:
+ tag = self.wire._send(TREAD, struct.pack('<IQI', self.fid, offset, 8192))
+ while True:
+ self.wire.socket.settimeout(None)
+ kind, rtag, body = self.wire._recv(None)
+ if rtag == tag:
+ break
+ if kind == RERROR:
+ break
+ chunk = body[4:]
+ if not chunk:
+ break
+ offset += len(chunk)
+ with self.cond:
+ self.lines.extend(chunk.decode(errors='replace').splitlines())
+ self.cond.notify_all()
+ except (Hang, Dropped, OSError, struct.error):
+ pass
+ with self.cond:
+ self.done = True
+ self.cond.notify_all()
+
+ def wait_for(self, needle, timeout=TIMEOUT):
+ deadline = time.monotonic() + timeout
+ with self.cond:
+ while True:
+ for i in range(len(self.lines) - 1, -1, -1):
+ if needle in self.lines[i]:
+ return i
+ left = deadline - time.monotonic()
+ if left <= 0 or self.done:
+ return None
+ self.cond.wait(left)
+
+ def stop(self):
+ self.done = True
+ try:
+ self.wire.socket.shutdown(socket.SHUT_RDWR)
+ except OSError:
+ pass
+ self.wire.socket.close()
+ self.thread.join(timeout=2)
+
+
+FIXTURES = {
+ 'alpha.txt': b'alpha beta gamma\nsecond line\n\nthird after a blank\n',
+ 'utf8.txt': 'naïve café 日本語 é \U0001f600\nline two\n'.encode(),
+ 'crlf.txt': b'one\r\ntwo\r\nthree\r\n',
+ 'bad.txt': b'hello \xff\xfe world\nlatin-1 caf\xe9\ntruncated \xe6\x97 here\n',
+ 'empty.txt': b'',
+ 'noeol.txt': b'no newline at the end',
+}
+
+
+# --------------------------------------------------------------------------
+# Results, registries.
+
+class Result:
+ def __init__(self):
+ self.requests = [] # (kind, path, ok, why)
+ self.hung_up = False # the op expected the server to hang up (Restore)
+ self.note = ''
+
+ def add(self, kind, path, ok, why=None):
+ self.requests.append((kind, path, ok, why))
+
+ def writes(self):
+ return [r for r in self.requests if r[0] in ('write', 'clunk-write', 'open-new', 'trunc')]
+
+
+GENERATORS = [] # (weight, fn)
+OPS = {} # name -> fn(sess, op) -> Result
+INVARIANTS = [] # fn(ctx, res) -> str | None
+
+
+def generator(weight):
+ def wrap(fn):
+ GENERATORS.append((weight, fn))
+ return fn
+ return wrap
+
+
+def op_kind(name):
+ def wrap(fn):
+ OPS[name] = fn
+ return fn
+ return wrap
+
+
+def invariant(fn):
+ INVARIANTS.append(fn)
+ return fn
+
+
+def host_path(path):
+ """Guard: nothing mutating ever touches the served host tree."""
+ parts = split(path)
+ return bool(parts) and parts[0] in (b'os', b'src')
+
+
+def is_held(path):
+ return (path.rstrip('/') == '/log' or path.endswith('/event') or
+ path.endswith('/pty/data') or path.endswith('/pty/run'))
+
+
+def slow(data):
+ return len(data) > 65536 or any(w in data for w in (b'Grep', b'Find', b'Dump', b'Restore', b'Tty', b'Edit'))
+
+
+# --------------------------------------------------------------------------
+# Op kinds: each takes a concrete op (what the ops log holds) and runs it.
+
+def dec(s):
+ return s.encode('latin-1')
+
+
+def enc(b):
+ return b.decode('latin-1')
+
+
+@op_kind('write')
+def op_write(sess, op):
+ """open(path, mode); write data in the given chunk sizes; clunk."""
+ res = Result()
+ path, data, mode = op['path'], dec(op['data']), op.get('mode', OWRITE)
+ if host_path(path):
+ res.note = 'refused by guard: host tree'
+ return res
+ w = sess.wire
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ timeout = SLOW_TIMEOUT if slow(data) else TIMEOUT
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout)
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ sizes = chunking(op.get('chunks', 'whole'), len(data), w.msize - 23)
+ opened = ok
+ sent = data
+ if ok:
+ offset = 0
+ took = []
+ for size in sizes:
+ piece = data[offset:offset + size]
+ ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout)
+ # A write on a fid opened for reading is 9P misuse, not a write
+ # the tree refused: the rule does not name it.
+ res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
+ offset += size
+ sent = data[:offset]
+ if ok:
+ took.append(size)
+ if not ok and op.get('stop_on_error', True):
+ break
+ # Misses are allowed the Twrites that succeeded: a line written a
+ # byte a Twrite is a look a byte, each its own write.
+ allowances(res, path, data[:sum(took)], took, w.msize - 24)
+ ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout)
+ res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body)
+ # What the open holds when it closes: the last line written, if unended.
+ if opened and mode & 3 and sent and not sent.endswith(b'\n'):
+ res.add('close-runs', path, ok)
+ return res
+
+
+def served_lines(data, sizes, room):
+ """The lines a lines file runs for `data` written in pieces of `sizes`
+ (fs.md, tree.zig write): each ended line, and the unended tail of a piece
+ shorter than a Twrite holds (`room`, msize less 24), which is whole in
+ itself, as acme takes each write; a piece that fills its Twrite may go
+ on, and its tail waits for the next. A line written a byte a Twrite is
+ so as many lines as bytes."""
+ lines, pending, at = [], b'', 0
+ for size in sizes:
+ piece = data[at:at + size]
+ at += size
+ pending += piece
+ *done, pending = pending.split(b'\n')
+ lines += done
+ if pending and len(piece) < room:
+ lines.append(pending)
+ pending = b''
+ if pending:
+ lines.append(pending)
+ return lines
+
+
+def allowances(res, path, data, sizes, room):
+ """Errs a successful write may log (fs.md): a look that finds nothing logs
+ one, per line, whether written to look, written back as an event record
+ (acme's xfid.c:842) or run as the Look builtin; an Edit block whose text
+ never ended runs, and fails, at the close."""
+ base = path.rsplit('/', 1)[-1]
+ lines = [line for line in served_lines(data, sizes, room) if line.strip()]
+ if base in ('look', 'event'):
+ n = len(lines)
+ elif base in ('exec', 'ctl', 'tagexec'):
+ n = sum(1 for line in lines if line.split()[:1] == [b'Look'])
+ if b'Edit' in data and len(lines) > 1:
+ n += 1
+ else:
+ return
+ if n:
+ res.add('allow', path, True, n)
+
+
+def open_kind(path, mode):
+ """An open the rule names: pane/new's (it makes a pane) and a truncation
+ ('every write or truncation the tree refused'); others are 'open'."""
+ if path.rstrip('/') == '/pane/new' and not mode & 3:
+ return 'open-new'
+ if mode & OTRUNC and mode & 3:
+ return 'trunc'
+ return 'open'
+
+
+def check_served_lines():
+ assert served_lines(b'a\nb\n', [4], 8000) == [b'a', b'b']
+ assert served_lines(b'ab\n', [1, 1, 1], 8000) == [b'a', b'b', b'']
+ assert served_lines(b'abcd', [2, 2], 2) == [b'abcd']
+ assert served_lines(b'ab\ncd', [3, 2], 8000) == [b'ab', b'cd']
+
+
+def chunking(spec, n, cap):
+ if n == 0:
+ return [0]
+ if spec == 'bytes':
+ return [1] * n
+ if isinstance(spec, list):
+ out, total = [], 0
+ for s in spec:
+ s = max(1, min(s, cap, n - total))
+ out.append(s)
+ total += s
+ if total >= n:
+ return out
+ while total < n:
+ s = min(cap, n - total)
+ out.append(s)
+ total += s
+ return out
+ out, total = [], 0
+ while total < n:
+ s = min(cap, n - total)
+ out.append(s)
+ total += s
+ return out
+
+
+@op_kind('read')
+def op_read(sess, op):
+ """open(path, OREAD); read from offset in chunks of count until EOF or cap."""
+ res = Result()
+ path, count, offset = op['path'], op.get('count', 8192), op.get('offset', 0)
+ w = sess.wire
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ mode = op.get('mode', OREAD)
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode))
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ if ok:
+ for _ in range(op.get('reads', 4)):
+ payload = struct.pack('<IQI', fid, offset, min(count, w.msize - 11))
+ if is_held(path):
+ ok, body = w.call_held(TREAD, payload)
+ else:
+ ok, body = w.call(TREAD, payload)
+ res.add('read', path, ok, body if ok is False else None)
+ if not ok or len(body) <= 4:
+ break
+ offset += len(body) - 4
+ ok, body = w.call(TCLUNK, struct.pack('<I', fid))
+ res.add('clunk', path, ok, None if ok else body)
+ return res
+
+
+@op_kind('stat')
+def op_stat(sess, op):
+ res = Result()
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TSTAT, struct.pack('<I', fid))
+ res.add('stat', op['path'], ok, None if ok else body)
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('remove')
+def op_remove(sess, op):
+ res = Result()
+ if host_path(op['path']):
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TREMOVE, struct.pack('<I', fid), SLOW_TIMEOUT)
+ res.add('remove', op['path'], ok, None if ok else body)
+ return res
+
+
+@op_kind('create')
+def op_create(sess, op):
+ res = Result()
+ if host_path(op['path']):
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(op['path']))
+ res.add('walk', op['path'], fid is not None, err)
+ if fid is not None:
+ ok, body = sess.wire.call(TCREATE, struct.pack('<I', fid) + string(dec(op['name'])) +
+ struct.pack('<IB', op.get('perm', 0o666), op.get('mode', OREAD)))
+ res.add('create', op['path'] + '/' + op['name'], ok, None if ok else body)
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('hold')
+def op_hold(sess, op):
+ """Open and keep a fid across later ops as handle op['h']."""
+ res = Result()
+ path, mode = op['path'], op.get('mode', OREAD)
+ if host_path(path) and mode & 3:
+ res.note = 'refused by guard: host tree'
+ return res
+ fid, err = sess.wire.walk_names(split(path))
+ if fid is None:
+ res.add('walk', path, False, err)
+ return res
+ ok, body = sess.wire.call(TOPEN, struct.pack('<IB', fid, mode))
+ res.add(open_kind(path, mode), path, ok, None if ok else body)
+ if ok:
+ sess.handles[op['h']] = [fid, path, mode, [0], b'']
+ else:
+ sess.wire.clunk(fid)
+ return res
+
+
+@op_kind('hwrite')
+def op_hwrite(sess, op):
+ res = Result()
+ h = sess.handles.get(op['h'])
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ fid, path, mode, off, _ = h
+ data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite
+ h[4] = data
+ ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data,
+ SLOW_TIMEOUT if slow(data) else TIMEOUT)
+ res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
+ if ok:
+ allowances(res, path, data, [len(data)], sess.wire.msize - 24)
+ if ok:
+ off[0] += len(data)
+ return res
+
+
+@op_kind('hread')
+def op_hread(sess, op):
+ res = Result()
+ h = sess.handles.get(op['h'])
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ fid, path, _, off, _ = h
+ offset = op['offset'] if op.get('offset') is not None else off[0]
+ payload = struct.pack('<IQI', fid, offset, min(op.get('count', 8192), sess.wire.msize - 11))
+ ok, body = sess.wire.call_held(TREAD, payload) if is_held(path) else sess.wire.call(TREAD, payload)
+ res.add('read', path, ok, body if ok is False else None)
+ if ok:
+ off[0] = offset + len(body) - 4
+ return res
+
+
+@op_kind('clunk')
+def op_clunk(sess, op):
+ res = Result()
+ h = sess.handles.pop(op['h'], None)
+ if h is None:
+ res.note = 'no such handle'
+ return res
+ ok, body = sess.wire.call(TCLUNK, struct.pack('<I', h[0]))
+ res.add('clunk-write' if h[2] & 3 else 'clunk', h[1], ok, None if ok else body)
+ if h[2] & 3 and h[4] and not h[4].endswith(b'\n'):
+ res.add('close-runs', h[1], ok)
+ return res
+
+
+@op_kind('restore')
+def op_restore(sess, op):
+ """Write Restore [f] to /ctl: refused with an Rerror, or the server
+ hangs up and serves the dump's panes again (fs.md: a Restore hangs its
+ connection up)."""
+ res = Result()
+ data = dec(op['data'])
+ try:
+ ok, why = sess.wire.write_path('/ctl', data)
+ res.add('write', '/ctl', ok, why)
+ if ok:
+ # Taken: the hangup follows the reply.
+ try:
+ sess.wire.call(TSTAT, struct.pack('<I', 1), 2.0)
+ except (Dropped, Hang):
+ pass
+ res.hung_up = True
+ except Dropped:
+ res.add('write', '/ctl', True, None)
+ res.hung_up = True
+ if res.hung_up:
+ sess.reconnect()
+ return res
+
+
+@op_kind('host')
+def op_host(sess, op):
+ """A change on disk under the session's HOME: write or delete a file."""
+ res = Result()
+ name = op['name']
+ assert '/' not in name and name not in ('.', '..')
+ path = os.path.join(sess.home, name)
+ if op['action'] == 'rm':
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+ else:
+ with open(path, 'wb') as f:
+ f.write(dec(op['data']))
+ return res
+
+
+@op_kind('seq')
+def op_seq(sess, op):
+ """Several ops as one step (e.g. clearing the window)."""
+ res = Result()
+ for sub in op['ops']:
+ r = OPS[sub['op']](sess, sub)
+ res.requests.extend(r.requests)
+ res.hung_up |= r.hung_up
+ return res
+
+
+# --------------------------------------------------------------------------
+# Input: data pools and generators (deliberately simple; swap freely).
+
+SAFE = (b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
+ b'-_=+,:#^@%()\n\r\t\0')
+UNSAFE = set(b'/~$`;|&<>.\\*?![]{}\'"')
+
+
+def shell_safe(data):
+ """No path or shell metacharacters, whatever else it holds."""
+ return bytes(b for b in data if b not in UNSAFE)
+
+
+RUNES = ['é', 'é', '日本', '\U0001f600', 'Δ', ' ', '​', '\u0080', '\u009f']
+ODD = [b'', b'\n', b'\r\n', b'\0', b'\xff', b'\xfe\xff', b'\xe6\x97', b'\xc0\x80', b'\x1b[2J', b'\x7f', b'\t',
+ b'\n\n\n', b' ', b' \n']
+WORDS = [b'alpha', b'beta', b'foo', b'bar', b'x', b'hello', b'zz', b'0', b'1', b'-1', b'99999999999',
+ b'on', b'off', b'follow', b'follow new', b'lock', b'unlock', b'clean', b'dirty', b'show',
+ b'dot=addr', b'addr=dot', b'limit=addr', b'nomark', b'mark', b'get', b'put', b'del', b'delete',
+ b'name', b'cleartag', b'answer', b'answer -', b'size 80 24', b'size 20 6', b'size 5000 5000',
+ b'winsize 80 24', b'winsize 0 0', b'sig INT', b'sig TERM', b'sig HUP', b'sig KILL', b'sig BOGUS',
+ b'exec', b'Placement acme', b'Placement pardes', b'Wrap on', b'Wrap off']
+SHELL_LINES = [b'true', b'false', b'echo hi', b'echo m9p', b'sleep 1', b'ls', b'cat nonexist',
+ b'printf abc', b'exit 3', b'seq 1 2000', b'yes | head -n 3000', b'date', b'pwd', b'env | wc']
+DENY = {'Exit', 'Attach', 'Detach', 'Mount', 'Unmount', 'Tty9p', 'ClipYank', 'ClipYankMain', 'ClipPaste',
+ 'ClipPasteBefore', 'ClipReplace', 'Shell', 'ThemeFile', 'EffectCode', 'Help', 'Tutor', 'Changelog',
+ 'Rename', 'Hover', 'CodeAction', 'SelectRefs', 'Symbols', 'Diagnostics', 'WsDiagnostics', 'Callers',
+ 'Callees', 'Supertypes', 'Subtypes', 'WsSymbols', 'Lspinfo', 'Lspwhy', 'Restore'}
+BUILTINS = ['Look', 'Exec', 'Kill', 'Dump', 'Msg', 'NextColor', 'Themes', 'DumpThemes', 'TreeContext', 'PdfFit',
+ 'PdfTint', 'PdfSections', 'Petscii', 'Palette', 'Ascii', 'Save', 'New', 'Newcol', 'Del', 'Filter',
+ 'Mode', 'Togglettymode', 'Edit', 'Undo', 'Redo', 'Collapse', 'Delcol', 'DelAbove', 'DelBelow', 'Tty',
+ 'Repl', 'Joincol', 'Config', 'LocationsConfig', 'Messages', 'Mini', 'Find', 'Grep', 'Left', 'Down',
+ 'Up', 'Right', 'Back', 'Forward', 'Last', 'Recent', 'Jumplist', 'Colors', 'Wrap', 'Tagbottom', 'Debug',
+ 'FocusTint', 'Verbose', 'Motion', 'InactiveDim', 'DumpDir', 'Theme', 'Placement', 'BootShell',
+ 'LookWord', 'Get', 'Put', 'Undo', 'Redo', 'Zerox', 'Snarf', 'Paste', 'Cut', 'Sort', 'Font', 'Local']
+PANE_FILES = ['name', 'body', 'tag', 'ctl', 'addr', 'dot', 'limit', 'data', 'xdata', 'sel', 'dirty', 'mark',
+ 'scroll', 'errors', 'event', 'look', 'exec', 'tagexec', 'pty', 'pty/run', 'pty/data', 'pty/ctl',
+ 'pty/status']
+ROOT_FILES = ['README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent', 'look', 'exec', 'log', 'screen',
+ 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'pane/new', 'col', '']
+COL_FILES = ['tag', 'ctl', 'exec']
+WRITABLE = ['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'body', 'data', 'xdata', 'addr', 'dot', 'limit',
+ 'name', 'sel', 'dirty', 'mark', 'scroll', 'errors', 'event', 'pty/run', 'pty/data', 'pty/ctl', 'log']
+
+
+class Ctx:
+ """What the generators and invariants see."""
+
+ def __init__(self, seed):
+ self.seed = seed
+ self.rng = random.Random(seed)
+ self.sess = None
+ self.step = 0
+ self.serials = []
+ self.cols = []
+ self.gone = [] # serials seen and since closed (stale)
+ self.next_h = 0
+ self.last_index = b''
+ self.verbose = False
+
+
+def pick_serial(ctx, rng):
+ """A live pane as a token, {pN}: the Nth serial of /index (mod its
+ length) when the op runs, so a shrunk replay still names a pane that
+ exists; or a stale or impossible serial, literally."""
+ r = rng.random()
+ if ctx.serials and r < 0.85:
+ return '{p%d}' % rng.randrange(64)
+ if ctx.gone and r < 0.95:
+ return rng.choice(ctx.gone)
+ return rng.choice([0, 1, 999, 4294967295, 4294967296, -1])
+
+
+def pick_col(ctx, rng):
+ if ctx.cols and rng.random() < 0.85:
+ return '{c%d}' % rng.randrange(16)
+ return rng.choice([0, 99, 4294967295])
+
+
+def odd_bytes(rng, n):
+ out = bytearray()
+ while len(out) < n:
+ r = rng.random()
+ if r < 0.5:
+ out += rng.choice(WORDS)
+ elif r < 0.7:
+ out += rng.choice(RUNES).encode()
+ elif r < 0.9:
+ out += rng.choice(ODD)
+ else:
+ out.append(rng.randrange(256))
+ if rng.random() < 0.5:
+ out += b' '
+ return bytes(out[:n])
+
+
+def payload(rng, maxlen=64):
+ """Plain or odd bytes, sometimes huge, with or without a newline."""
+ r = rng.random()
+ if r < 0.35:
+ data = rng.choice(WORDS)
+ elif r < 0.55:
+ data = odd_bytes(rng, rng.randrange(0, maxlen))
+ elif r < 0.62:
+ data = rng.choice(ODD)
+ elif r < 0.66:
+ data = (rng.choice(WORDS) + b' ') * rng.randrange(100, 20000) # huge
+ elif r < 0.7:
+ data = b'x' * rng.choice([255, 256, 1023, 1024, 1025, 65535, 65536, 70000])
+ else:
+ data = rng.choice(WORDS) + b' ' + odd_bytes(rng, rng.randrange(0, 12))
+ if rng.random() < 0.6 and not data.endswith(b'\n'):
+ data += rng.choice([b'\n', b'\n', b'\r\n', b'\n\n'])
+ return shell_safe(data)
+
+
+def weird_name(rng):
+ r = rng.random()
+ if r < 0.3:
+ return 'x' * rng.choice([255, 256, 300, 1000])
+ if r < 0.5:
+ return rng.choice(['..', '.', '', ' ', 'new', 'NEW', '01', '1 ', '\n', 'a\0b'])
+ if r < 0.7:
+ return enc(rng.choice(RUNES).encode())
+ return enc(bytes(rng.randrange(1, 256) for _ in range(rng.randrange(1, 20))).replace(b'/', b'_'))
+
+
+def random_path(ctx, rng, writable=False):
+ r = rng.random()
+ if r < 0.6:
+ f = rng.choice(WRITABLE if writable else PANE_FILES)
+ return f'/pane/{pick_serial(ctx, rng)}/{f}'
+ if r < 0.75:
+ f = rng.choice(['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'log'] if writable else ROOT_FILES)
+ return '/' + f
+ if r < 0.85:
+ return f'/col/{pick_col(ctx, rng)}/{rng.choice(COL_FILES)}'
+ if r < 0.93:
+ return f'/pane/{pick_serial(ctx, rng)}/{weird_name(rng)}'
+ return '/' + '/'.join(weird_name(rng) for _ in range(rng.randrange(1, 4)))
+
+
+def data_for(ctx, rng, path):
+ """Payload a file is likely to take, often mutated."""
+ base = path.rsplit('/', 1)[-1]
+ r = rng.random()
+ if r < 0.25:
+ return payload(rng)
+ if base in ('addr', 'dot', 'limit', 'sel'):
+ return address(rng)
+ if base == 'ctl' and path.endswith('pty/ctl'):
+ return rng.choice([b'sig INT\n', b'sig TERM\n', b'sig HUP\n', b'sig QUIT\n', b'sig KILL\n',
+ b'winsize 80 24\n', b'winsize 1 1\n', b'winsize 99999 3\n', b'exec\n', b'sig\n'])
+ if base in ('ctl', 'exec', 'tagexec'):
+ return builtin_line(ctx, rng, path)
+ if base == 'run':
+ return shell_safe(rng.choice(SHELL_LINES)) + b'\n'
+ if base == 'data' and '/pty/' in path:
+ return shell_safe(rng.choice(SHELL_LINES)) + rng.choice([b'\r', b'\n', b'', b'\x03', b'\x04'])
+ if base == 'event':
+ return event_record(rng)
+ if base == 'focus':
+ return str(pick_serial(ctx, rng)).encode() + rng.choice([b'\n', b''])
+ if base == 'look':
+ return shell_safe(rng.choice([b'alpha', b'alpha.txt', b'alpha.txt:2', b'utf8.txt:#3', b'nothere',
+ b'bad.txt', b'crlf.txt:1', b'gamma', b'beta'])) + b'\n'
+ if base in ('dirty', 'mark', 'scroll'):
+ return rng.choice([b'0', b'1', b'0\n', b'1\n', b'2\n', b'', b'yes\n'])
+ if base == 'log':
+ return rng.choice([b'follow\n', b'follow new\n', b'follow', b'nope\n'])
+ return payload(rng, 200)
+
+
+def mutate(rng, data, rounds=None):
+ data = bytearray(data)
+ for _ in range(rounds or rng.randrange(1, 4)):
+ if not data:
+ data += rng.choice(WORDS)
+ continue
+ i = rng.randrange(len(data))
+ r = rng.random()
+ if r < 0.3:
+ del data[i]
+ elif r < 0.55:
+ data[i:i] = shell_safe(rng.choice(ODD) or b'\0')
+ elif r < 0.75:
+ j = rng.randrange(i, len(data) + 1)
+ data[i:i] = data[i:j]
+ else:
+ data[i:i] = rng.choice(RUNES).encode()
+ return bytes(data)
+
+
+def builtin_line(ctx, rng, path):
+ word = rng.choice(BUILTINS).encode()
+ r = rng.random()
+ if r < 0.4:
+ line = word
+ elif r < 0.7:
+ line = word + b' ' + rng.choice(WORDS)
+ elif r < 0.8:
+ line = b'Edit ' + edit_command(rng)
+ return line + b'\n' # Edit text keeps its / delimiters
+ else:
+ line = mutate(rng, word + b' ' + rng.choice(WORDS))
+ if rng.random() < 0.1:
+ line = shell_safe(rng.choice(SHELL_LINES))
+ if rng.random() < 0.8:
+ line += b'\n'
+ return shell_safe(line)
+
+
+# sam's commands without the ones that reach files or shells.
+EDIT_TEXT = b'abcdghijklmopqstuvxyz0123456789 ACEGHIJKLMNOPQRSTUVWXYZ_-'
+
+
+def edit_text(rng):
+ s = bytes(rng.choice(EDIT_TEXT) for _ in range(rng.randrange(0, 8)))
+ if rng.random() < 0.3:
+ s += rng.choice(RUNES).encode()
+ return s
+
+
+def regex(rng):
+ parts = [edit_text(rng) or b'a']
+ for _ in range(rng.randrange(0, 4)):
+ parts.append(rng.choice([b'(a|b)', b'^', b'$', b'[a-z]', b'[^ ]', b'.*', b'\\n',
+ '[é日]'.encode(), b'x+', b'(', b'[', b'\\', b'a**',
+ b'(((a)))', b'[z-a]', (b'a|' * rng.randrange(1, 300)) + b'b']))
+ parts.append(edit_text(rng))
+ return b''.join(parts)
+
+
+def address(rng):
+ """sam addresses: valid, near-valid and garbage."""
+ simple = [b'0', b'$', b'.', b',', b';', b'#0', b'#3', b'#99999', b'1', b'2', b'3:2', b'1:1', b'0:0',
+ b'#1,#2', b'1,$', b'2,1', b'-', b'+', b'-1', b'+2', b'#-1', b'99999', b'1:99', b'?a?']
+ r = rng.random()
+ if r < 0.4:
+ a = rng.choice(simple)
+ elif r < 0.7:
+ a = b'/' + regex(rng) + b'/'
+ elif r < 0.85:
+ a = rng.choice(simple) + rng.choice([b',', b';', b'+', b'-']) + rng.choice(simple + [b'/a/'])
+ else:
+ a = mutate(rng, rng.choice(simple) + b'/' + regex(rng) + b'/')
+ return a + rng.choice([b'', b'\n'])
+
+
+def edit_command(rng):
+ t = lambda: edit_text(rng) # noqa: E731
+ cmds = [lambda: b',x/' + regex(rng) + b'/c/' + t() + b'/',
+ lambda: b's/' + regex(rng) + b'/' + t() + b'/g',
+ lambda: b'a/' + t() + b'/',
+ lambda: b'i/' + t() + b'/',
+ lambda: b'c/' + t() + b'/',
+ lambda: b'd',
+ lambda: b',d',
+ lambda: b',y/' + regex(rng) + b'/d',
+ lambda: b',x/' + regex(rng) + b'/g/' + regex(rng) + b'/d',
+ lambda: b',x/' + regex(rng) + b'/v/' + regex(rng) + b'/c/' + t() + b'/',
+ lambda: b'1,2m$',
+ lambda: b'1t0',
+ lambda: b'k',
+ lambda: b'p',
+ lambda: b'=',
+ lambda: b'u',
+ lambda: b'{\na/' + t() + b'/\ni/' + t() + b'/\n}',
+ lambda: b',x/\\n/a/' + t() + b'/',
+ lambda: b'0,$s/' + regex(rng) + b'/&&/g']
+ cmd = rng.choice(cmds)()
+ if rng.random() < 0.4:
+ cmd = address(rng).rstrip(b'\n') + cmd
+ if rng.random() < 0.2:
+ cmd = mutate(rng, cmd)
+ # Mutation never introduces a banned command letter or shell char.
+ cmd = bytes(b for b in cmd if b not in b'werfbBDn!<>|')
+ return cmd
+
+
+def event_record(rng):
+ origin = rng.choice(b'EFKMZ')
+ action = rng.choice(b'xXlLiIdDZ')
+ q0, q1 = rng.choice([0, 1, 5, 10]), rng.choice([0, 2, 7, 1 << 40, -1])
+ rec = bytes([origin, action]) + f'{q0} {q1} {rng.choice([0, 1, 2, 8])} '.encode()
+ text = shell_safe(edit_text(rng))
+ rec += str(len(text)).encode() + b' ' + text + b'\n'
+ if rng.random() < 0.3:
+ rec = mutate(rng, rec)
+ return shell_safe(rec)
+
+
+def new_handle(ctx):
+ ctx.next_h += 1
+ return f'h{ctx.next_h}'
+
+
+@generator(30)
+def gen_write(ctx, rng):
+ path = random_path(ctx, rng, writable=True)
+ data = data_for(ctx, rng, path)
+ mode = rng.choice([OWRITE, OWRITE, ORDWR, OWRITE | OTRUNC, ORDWR | OTRUNC, OREAD])
+ r = rng.random()
+ chunks = 'bytes' if r < 0.08 and len(data) <= 64 else (
+ [rng.randrange(1, 16) for _ in range(4)] if r < 0.15 else 'whole')
+ op = {'op': 'write', 'path': path, 'data': enc(data), 'mode': mode, 'chunks': chunks}
+ if rng.random() < 0.05:
+ op['offset'] = rng.choice([1, 100, 1 << 40])
+ return op
+
+
+@generator(14)
+def gen_read(ctx, rng):
+ path = random_path(ctx, rng)
+ return {'op': 'read', 'path': path, 'count': rng.choice([1, 2, 7, 64, 200, 8192, 1 << 20]),
+ 'offset': rng.choice([0, 0, 0, 1, 3, 100, 1 << 33]), 'reads': rng.choice([1, 2, 8])}
+
+
+@generator(4)
+def gen_stat(ctx, rng):
+ path = random_path(ctx, rng)
+ if rng.random() < 0.2:
+ path = '/os/' + rng.choice(['etc', 'etc/hosts', 'nonexistent', 'x' * 300])
+ return {'op': 'stat', 'path': path}
+
+
+@generator(5)
+def gen_newpane(ctx, rng):
+ if rng.random() < 0.7:
+ return {'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1}
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': '/pane/new', 'mode': rng.choice([OREAD, ORDWR])}
+
+
+@generator(2)
+def gen_remove(ctx, rng):
+ r = rng.random()
+ if r < 0.6:
+ path = f'/pane/{pick_serial(ctx, rng)}'
+ elif r < 0.85:
+ path = f'/col/{pick_col(ctx, rng)}'
+ else:
+ path = random_path(ctx, rng)
+ return {'op': 'remove', 'path': path}
+
+
+@generator(2)
+def gen_create(ctx, rng):
+ where = rng.choice(['/col', '/pane', '/', f'/pane/{pick_serial(ctx, rng)}'])
+ return {'op': 'create', 'path': where, 'name': weird_name(rng) if rng.random() < 0.5 else 'new',
+ 'perm': rng.choice([0o666, DMDIR | 0o777]), 'mode': rng.choice([OREAD, OWRITE])}
+
+
+@generator(2)
+def gen_clear_window(ctx, rng):
+ """Down to the empty window (every pane closed), sometimes rebuilt."""
+ keep = rng.choice(ctx.serials) if ctx.serials and rng.random() < 0.8 else None
+ ops = [{'op': 'remove', 'path': f'/pane/{s}'} for s in ctx.serials if s != keep]
+ if rng.random() < 0.5:
+ ops += [{'op': 'remove', 'path': f'/col/{c}'} for c in ctx.cols]
+ if rng.random() < 0.6:
+ ops.append({'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1})
+ return {'op': 'seq', 'ops': ops}
+
+
+@generator(6)
+def gen_hold(ctx, rng):
+ held = [h for h in (ctx.sess.handles if ctx.sess else {})]
+ r = rng.random()
+ if not held or r < 0.35:
+ path = random_path(ctx, rng)
+ if rng.random() < 0.3:
+ path = rng.choice(['/log', f'/pane/{pick_serial(ctx, rng)}/event', '/screen', '/index',
+ f'/pane/{pick_serial(ctx, rng)}/ctl'])
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': path, 'mode': rng.choice([OREAD, ORDWR, OWRITE])}
+ h = rng.choice(held)
+ path = ctx.sess.handles[h][1]
+ if r < 0.6:
+ return {'op': 'hwrite', 'h': h, 'data': enc(data_for(ctx, rng, path))}
+ if r < 0.85:
+ return {'op': 'hread', 'h': h, 'count': rng.choice([1, 16, 8192]),
+ 'offset': rng.choice([None, None, 0, 5])}
+ return {'op': 'clunk', 'h': h}
+
+
+@generator(6)
+def gen_shell(ctx, rng):
+ s = pick_serial(ctx, rng)
+ line = shell_safe(rng.choice(SHELL_LINES))
+ r = rng.random()
+ if r < 0.25:
+ return {'op': 'write', 'path': f'/pane/{s}/ctl', 'data': enc(b'Tty\n'), 'mode': OWRITE}
+ if r < 0.45:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/run', 'data': enc(line + b'\n'), 'mode': ORDWR}
+ if r < 0.6:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/data', 'data': enc(line + b'\r'), 'mode': OWRITE}
+ if r < 0.75:
+ return {'op': 'write', 'path': f'/pane/{s}/pty/ctl',
+ 'data': enc(rng.choice([b'sig INT\n', b'sig TERM\n', b'sig KILL\n', b'sig HUP\n'])), 'mode': OWRITE}
+ return {'op': 'write', 'path': rng.choice(['/exec', f'/pane/{s}/exec']), 'data': enc(line + b'\n'),
+ 'mode': OWRITE}
+
+
+@generator(6)
+def gen_edit(ctx, rng):
+ s = pick_serial(ctx, rng)
+ path = rng.choice([f'/pane/{s}/ctl', f'/pane/{s}/exec', '/exec'])
+ return {'op': 'write', 'path': path, 'data': enc(b'Edit ' + edit_command(rng) + b'\n'), 'mode': OWRITE}
+
+
+@generator(8)
+def gen_addr(ctx, rng):
+ s = pick_serial(ctx, rng)
+ ops = [{'op': 'write', 'path': f'/pane/{s}/addr', 'data': enc(address(rng)), 'mode': OWRITE}]
+ r = rng.random()
+ if r < 0.4:
+ ops.append({'op': 'read', 'path': f'/pane/{s}/{rng.choice(["xdata", "data", "addr", "dot"])}',
+ 'count': 8192, 'reads': 2})
+ elif r < 0.7:
+ ops.append({'op': 'write', 'path': f'/pane/{s}/{rng.choice(["data", "xdata"])}',
+ 'data': enc(payload(rng, 20)), 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])})
+ return {'op': 'seq', 'ops': ops}
+
+
+@generator(3)
+def gen_tag(ctx, rng):
+ path = rng.choice([f'/pane/{pick_serial(ctx, rng)}/tag', '/tag', f'/col/{pick_col(ctx, rng)}/tag'])
+ return {'op': 'write', 'path': path, 'data': enc(payload(rng, 40)),
+ 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])}
+
+
+@generator(3)
+def gen_event(ctx, rng):
+ s = pick_serial(ctx, rng)
+ return {'op': 'hold', 'h': new_handle(ctx), 'path': f'/pane/{s}/event', 'mode': ORDWR}
+
+
+@generator(2)
+def gen_dump_restore(ctx, rng):
+ r = rng.random()
+ if r < 0.5:
+ return {'op': 'write', 'path': '/ctl', 'data': enc(rng.choice([b'Dump\n', b'Dump', b'Dump m9pdump\n'])),
+ 'mode': OWRITE}
+ return {'op': 'restore', 'data': enc(rng.choice([b'Restore\n', b'Restore\n', b'Restore m9pdump\n',
+ b'Restore nothere\n']))}
+
+
+@generator(3)
+def gen_host(ctx, rng):
+ name = rng.choice(list(FIXTURES) + ['fresh.txt'])
+ if rng.random() < 0.5:
+ return {'op': 'host', 'action': 'rm', 'name': name}
+ return {'op': 'host', 'action': 'write', 'name': name, 'data': enc(payload(rng, 100))}
+
+
+@generator(3)
+def gen_look_fixture(ctx, rng):
+ name = rng.choice(list(FIXTURES) + ['fresh.txt'])
+ suffix = rng.choice([b'', b':2', b':#4', b':1:3', b':/beta/'])
+ return {'op': 'write', 'path': rng.choice(['/look', f'/pane/{pick_serial(ctx, rng)}/look']),
+ 'data': enc(name.encode() + suffix + b'\n'), 'mode': OWRITE}
+
+
+# --------------------------------------------------------------------------
+# Log window: the records one step caused, between two unique markers.
+
+MARK = re.compile(r'unknown control message "(m9p-mark-\d+)"')
+
+
+def write_marker(sess, step):
+ """An unknown ctl word logs `err - ctl: unknown control message "<word>"`."""
+ word = f'm9p-mark-{step}'
+ ok, why = sess.wire.write_path('/ctl', word.encode() + b'\n')
+ if ok:
+ return None, 'marker write was taken'
+ prev, sess.marker = sess.marker, word
+ if sess.follower is not None:
+ end = sess.follower.wait_for(f'"{word}"')
+ if end is None:
+ return None, 'marker record never reached the follower'
+ lines = sess.follower.lines[:end]
+ start = 0
+ if prev is not None:
+ for i in range(len(lines) - 1, -1, -1):
+ if f'"{prev}"' in lines[i]:
+ start = i + 1
+ break
+ else:
+ return None, None
+ window = lines[start:]
+ del sess.follower.lines[:end]
+ return window, None
+ lines = sess.wire.read_path('/log').decode(errors='replace').splitlines()
+ end = next((i for i in range(len(lines) - 1, -1, -1) if f'"{word}"' in lines[i]), None)
+ if end is None:
+ return None, 'marker record missing from /log'
+ if prev is None:
+ return None, None
+ start = next((i for i in range(end - 1, -1, -1) if f'"{prev}"' in lines[i]), None)
+ if start is None:
+ return None, None # the ring moved past it
+ return lines[start + 1:end], None
+
+
+COUNT = re.compile(r'^(.*) \(x(\d+)\)$')
+
+
+def occurrences(lines, kind):
+ """Records of `kind` (err|msg), counting `(xN)` repeats (fs.md: a record said
+ again is counted; a follower sees each count as a line of its own)."""
+ out, last_text, last_n = [], None, 0
+ for line in lines:
+ if not line.startswith(kind + ' '):
+ last_text = None
+ continue
+ m = COUNT.match(line)
+ text, n = (m.group(1), int(m.group(2))) if m else (line, 1)
+ if text == last_text:
+ new = n - last_n
+ else:
+ new = n
+ out.extend([text] * max(new, 0))
+ last_text, last_n = text, n
+ return out
+
+
+# --------------------------------------------------------------------------
+# Invariants: small, cheap, each with its doc citation.
+
+@invariant
+def alive(ctx, res):
+ """Never crashes or panics (src/crash.zig: a panic leaves its crash file)."""
+ if not ctx.sess.alive():
+ return f'pardes died (exit {ctx.sess.proc.returncode})'
+ if ctx.sess.has_crash_file():
+ return 'crash file written: ' + ctx.sess.crash_text()[-2000:]
+ return None
+
+
+@invariant
+def one_failure_rule(ctx, res):
+ """fs.md 'One rule for what fails': a write fails whenever what it asked
+ for fails ... and logs its reason exactly once, as `err <serial|-> <file>:
+ <why>`, with no `msg` for it. What is not a failure: a look that finds
+ nothing answers nothing and logs one `err`, the write succeeding."""
+ if res.window is None or res.hung_up:
+ return None
+ writes = res.writes()
+ # The rule is a write's: a refused open (pane/new's and an OTRUNC one
+ # too), create or remove logs no err, nor does a write to pane/new
+ # (`permission denied`: it is only read).
+ failed = [w for w in writes if w[2] is False and w[0] in ('write', 'clunk-write')
+ and w[1].rstrip('/') != '/pane/new']
+ errs = occurrences(res.window, 'err')
+ msgs = occurrences(res.window, 'msg')
+ # Allowed only the Twrites that succeeded (allowances): a failed one
+ # logs its one err.
+ allowed = sum(r[3] for r in res.requests if r[0] == 'allow')
+ # fs.md 'A write of command lines ... what is left when it closes runs at
+ # the close ... and its failure is in the log alone'.
+ closes = [r for r in res.requests if r[0] == 'close-runs']
+ lo, hi = len(failed), len(failed) + allowed + len(closes)
+ if not lo <= len(errs) <= hi:
+ return (f'{len(failed)} failed write(s) logged {len(errs)} err record(s) '
+ f'(allowed {lo}..{hi}); failed={[(w[0], w[1], w[3]) for w in failed]} window={res.window}')
+ if failed and msgs:
+ return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}'
+ return None
+
+
+LINE_FILES = ('look', 'exec', 'tagexec', 'ctl')
+
+
+@invariant
+def close_runs_only_line_files(ctx, res):
+ """fs.md: 'A write of command lines -- to look, exec, tagexec, a ctl of the
+ root, a pane or a column, or a column's exec -- runs each line once it is
+ whole ... what is left when it closes runs at the close'. Another file
+ that fails only at its close breaks 'a write fails whenever what it asked
+ for fails' (or the list is short)."""
+ if res.window is None:
+ return None
+ closes = [r[1] for r in res.requests if r[0] == 'close-runs']
+ failed_files = {w[1].rsplit('/', 1)[-1] for w in res.writes() if w[2] is False}
+ for line in occurrences(res.window, 'err'):
+ m = re.match(r'err \S+ ([^:]+):', line)
+ if not m:
+ continue
+ name = m.group(1).rsplit('/', 1)[-1]
+ if name in LINE_FILES or name in failed_files:
+ continue
+ for path in closes:
+ if path.rsplit('/', 1)[-1] == name:
+ return f'a write to {name} with no newline was answered, then failed at the close: {line}'
+ return None
+
+
+@invariant
+def index_matches_pane_dirs(ctx, res):
+ """fs.md /index: one line per pane; /pane/<n>/ each pane's directory."""
+ w = ctx.sess.wire
+ first = w.read_path('/index')
+ fid, err = w.walk_names([b'pane'])
+ if fid is None:
+ return f'walking /pane failed: {err}'
+ ok, _ = w.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ names = dir_names(w.read_all(fid))
+ w.clunk(fid)
+ second = w.read_path('/index')
+ ctx.last_index = second
+ serials = []
+ for line in second.decode(errors='replace').splitlines():
+ try:
+ serials.append(int(line.split()[0]))
+ except (IndexError, ValueError):
+ return f'/index line unparsable: {line!r}'
+ old = set(ctx.serials)
+ ctx.serials = serials
+ ctx.gone = sorted(set(ctx.gone) | (old - set(serials)))[-20:]
+ if first != second:
+ return None # it changed between the reads; no claim
+ dirs = sorted(int(n) for n in names if n.isdigit())
+ extra = [n for n in names if not n.isdigit() and n != 'new']
+ if extra:
+ return f'/pane lists non-serial entries {extra}'
+ if sorted(serials) != dirs:
+ return f'/index serials {sorted(serials)} != /pane dirs {dirs}'
+ return None
+
+
+LISTINGS = ['/index', '/layout', '/', '/pane', '/col', '/pane/{s}', '/status', '/commands', '/pane/{s}/tag',
+ '/recent', '/README']
+
+
+@invariant
+def chunked_equals_whole(ctx, res):
+ """9P read(5): reads at successive offsets see the one file; a listing read
+ in chunks equals one read whole within the same open."""
+ w = ctx.sess.wire
+ path = LISTINGS[ctx.step % len(LISTINGS)]
+ if '{s}' in path:
+ if not ctx.serials:
+ return None
+ path = path.format(s=ctx.serials[ctx.step % len(ctx.serials)])
+ fid, err = w.walk_names(split(path))
+ if fid is None:
+ return None # gone since
+ try:
+ ok, body = w.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ if not ok:
+ return None
+ whole = w.read_all(fid)
+ isdir = path in ('/', '/pane', '/col') or path.count('/') == 2 and path.startswith('/pane/')
+ size = 128 + (ctx.step * 37) % 900 if isdir else 1 + (ctx.step * 7) % 50
+ chunked = w.read_all(fid, count=size)
+ if isdir:
+ whole_n, chunk_n = dir_names(whole), dir_names(chunked)
+ if whole_n != chunk_n:
+ return f'{path}: whole {whole_n} != chunked({size}) {chunk_n}'
+ elif whole != chunked:
+ return f'{path}: whole {whole[:200]!r} != chunked({size}) {chunked[:200]!r}'
+ finally:
+ w.clunk(fid)
+ return None
+
+
+# --------------------------------------------------------------------------
+# Runner.
+
+class Failure(Exception):
+ def __init__(self, kind, why, step):
+ super().__init__(f'{kind}: {why}')
+ self.kind, self.why, self.step = kind, why, step
+
+ def signature(self):
+ """What makes two failures the same bug: the kind, and the message with
+ numbers, quoted words and pane paths blurred."""
+ why = re.sub(r'"[^"]*"', '""', self.why)
+ why = re.sub(r"'[^']*'", "''", why) if 'window=' not in why else why
+ why = re.sub(r'/home/\S*', 'PATH', why)
+ return self.kind + ':' + re.sub(r'\d+', 'N', why)[:300]
+
+
+def check_death(sess, step, what):
+ """After an error: did pardes quit cleanly (Ended), or die (Failure)?"""
+ deadline = time.monotonic() + 1.0
+ while sess.alive() and time.monotonic() < deadline:
+ time.sleep(0.02)
+ if sess.alive():
+ return
+ if sess.proc.returncode == 0 and not sess.has_crash_file():
+ raise Ended(what)
+ raise Failure('crash', f'{what}: pardes exit {sess.proc.returncode} ' + sess.crash_text()[:3000], step)
+
+
+TOKEN = re.compile(r'\{([pc])(\d+)\}')
+
+
+def resolve(ctx, op):
+ """Replace {pN}/{cN} in an op's paths with live serials."""
+ def sub(m):
+ live = ctx.serials if m.group(1) == 'p' else ctx.cols
+ return str(live[int(m.group(2)) % len(live)]) if live else '0'
+ out = dict(op)
+ if 'path' in out:
+ out['path'] = TOKEN.sub(sub, out['path'])
+ if 'ops' in out:
+ out['ops'] = [resolve(ctx, o) for o in out['ops']]
+ if 'data' in out and '/focus' in out.get('path', ''):
+ out['data'] = TOKEN.sub(sub, out['data'])
+ return out
+
+
+def run_step(ctx, op):
+ """Run one op and every invariant. Raises Failure or Ended."""
+ sess = ctx.sess
+ op = resolve(ctx, op)
+ ctx.last_op = op
+ what = f'{op["op"]} {op.get("path", "")}'.strip()
+ try:
+ res = OPS[op['op']](sess, op)
+ except Hang as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('hang', f'{what}: {why}', ctx.step)
+ except Dropped as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('dropped', f'{what}: {why}', ctx.step)
+ res.window = None
+ try:
+ if not sess.alive():
+ check_death(sess, ctx.step, what)
+ # Liveness: a trivial request answers within the timeout.
+ ok, _ = sess.wire.call(TSTAT, struct.pack('<I', 1))
+ window, why = write_marker(sess, ctx.step)
+ if why:
+ raise Failure('log', why, ctx.step)
+ res.window = None if res.hung_up else window
+ if ctx.verbose:
+ print(f' op {ctx.step}: {json.dumps(op)[:300]}')
+ for r in res.requests:
+ print(f' {r[0]} {r[1]!r} -> {"ok" if r[2] else ("held" if r[2] is None else "Rerror")}'
+ f'{": " + str(r[3]) if r[3] else ""}')
+ for line in window or []:
+ print(f' log: {line}')
+ for check in INVARIANTS:
+ problem = check(ctx, res)
+ if problem:
+ raise Failure(check.__name__, problem, ctx.step)
+ # The column list, for the generators.
+ fid, err = sess.wire.walk_names([b'col'])
+ if fid is not None:
+ sess.wire.call(TOPEN, struct.pack('<IB', fid, OREAD))
+ ctx.cols = [int(n) for n in dir_names(sess.wire.read_all(fid)) if n.isdigit()]
+ sess.wire.clunk(fid)
+ except Hang as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('hang', f'after {what}: {why}', ctx.step)
+ except Dropped as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('dropped', f'after {what}: {why}', ctx.step)
+ except Failure as f:
+ # An invariant that failed because pardes was quitting is not one.
+ check_death(sess, ctx.step, what)
+ raise f
+ except OSError as why:
+ check_death(sess, ctx.step, what)
+ raise Failure('checker', f'after {what}: {why}', ctx.step)
+ return res
+
+
+class Runner:
+ def __init__(self, args):
+ self.args = args
+ self.out = args.out
+ os.makedirs(os.path.join(self.out, 'bugs'), exist_ok=True)
+ os.makedirs(os.path.join(self.out, 'runs'), exist_ok=True)
+
+ def new_session(self, ctx, follower, run_dir):
+ if ctx.sess is not None:
+ ctx.sess.close()
+ ctx.sess = Session(self.args.binary, run_dir, follower)
+ ctx.serials, ctx.cols, ctx.gone = [], [], []
+ idx = ctx.sess.wire.read_path('/index')
+ ctx.serials = [int(line.split()[0]) for line in idx.decode(errors='replace').splitlines() if line]
+
+ def execute(self, ops, run_dir, ctx=None, log=None, stop_on_failure=True, verbose=False):
+ """Run concrete ops (a replay). Returns the first Failure or None."""
+ ctx = ctx or Ctx(0)
+ ctx.verbose = verbose
+ failure = None
+ try:
+ for i, op in enumerate(ops):
+ ctx.step = i
+ if op['op'] == 'fresh':
+ self.new_session(ctx, op.get('follower', False), run_dir)
+ continue
+ if ctx.sess is None:
+ self.new_session(ctx, False, run_dir)
+ try:
+ run_step(ctx, op)
+ except Ended:
+ ctx.sess.close()
+ ctx.sess = None
+ except Failure as f:
+ failure = f
+ if verbose:
+ print(f'step {i}: {f}')
+ if stop_on_failure:
+ break
+ finally:
+ if ctx.sess is not None:
+ ctx.sess.close(keep=False)
+ ctx.sess = None
+ return failure
+
+ def fuzz(self, seed, steps):
+ name = f'seed-{seed}'
+ run_dir = os.path.join(self.out, 'runs', name)
+ shutil.rmtree(run_dir, ignore_errors=True)
+ os.makedirs(run_dir)
+ ctx = Ctx(seed)
+ rng = ctx.rng
+ log_path = os.path.join(run_dir, 'ops.jsonl')
+ segment = []
+ found = collections.OrderedDict()
+ t0 = time.monotonic()
+ weights = [w for w, _ in GENERATORS]
+ ended = 0
+ with open(log_path, 'w') as log:
+ log.write(json.dumps({'seed': seed, 'steps': steps, 'binary': self.args.binary}) + '\n')
+
+ def fresh():
+ op = {'op': 'fresh', 'follower': rng.random() < 0.5}
+ log.write(json.dumps(op) + '\n')
+ segment.clear()
+ segment.append(op)
+ self.new_session(ctx, op['follower'], run_dir)
+
+ fresh()
+ for step in range(steps):
+ ctx.step = step
+ gen = rng.choices(GENERATORS, weights)[0][1]
+ op = gen(ctx, rng)
+ log.write(json.dumps(op) + '\n')
+ log.flush()
+ segment.append(op)
+ f = None
+ try:
+ run_step(ctx, op)
+ except Ended:
+ ended += 1
+ fresh()
+ continue
+ except Failure as failure:
+ f = failure
+ except Exception as why: # a harness bug: say so, carry on
+ import traceback
+ f = Failure('harness', f'{type(why).__name__}: {why} ' +
+ traceback.format_exc()[-1500:], step)
+ if f is not None:
+ sig = f.signature()
+ first = sig not in found
+ found.setdefault(sig, []).append(step)
+ print(f'[seed {seed} step {step}] FAIL {f.kind}: {f.why[:400]}', flush=True)
+ if first:
+ crash = ctx.sess.crash_text()
+ ctx.sess.close(keep=True)
+ ctx.sess = None
+ self.record(seed, step, f, list(segment), crash)
+ fresh()
+ if self.args.progress and step and step % self.args.progress == 0:
+ rate = step / (time.monotonic() - t0)
+ print(f'[seed {seed}] step {step}/{steps} {rate:.0f} steps/s, {len(found)} distinct failure(s)',
+ flush=True)
+ if ctx.sess is not None:
+ ctx.sess.close()
+ dt = time.monotonic() - t0
+ print(f'[seed {seed}] {steps} steps in {dt:.0f}s ({steps / dt:.0f}/s), {ended} clean quit(s); '
+ f'ops log {log_path}')
+ for sig, at in found.items():
+ print(f' {len(at)}x {sig[:200]} (first at step {at[0]})')
+ return found
+
+ def record(self, seed, step, f, ops, crash):
+ digest = hashlib.sha1(f.signature().encode()).hexdigest()[:8]
+ path = os.path.join(self.out, 'bugs', f'{f.kind}-{digest}-seed{seed}-step{step}.jsonl')
+ header = {'seed': seed, 'step': step, 'kind': f.kind, 'why': f.why, 'signature': f.signature(),
+ 'crash': crash[-4000:], 'binary': self.args.binary}
+ write_ops(path, header, ops)
+ print(f' bug record {path}', flush=True)
+ if not self.args.no_shrink:
+ self.shrink(path)
+
+ def shrink(self, path):
+ header, ops = read_ops(path)
+ # The first replay pins the signature (this code's, which may have
+ # changed since the record was written); later ones must match it.
+ sig = None
+ kind = header.get('kind')
+ run_dir = os.path.join(self.out, 'runs', f'shrink-{os.getpid()}')
+ os.makedirs(run_dir, exist_ok=True)
+ follower = ops[0].get('follower', False) if ops and ops[0]['op'] == 'fresh' else False
+ body = [op for op in ops if op['op'] != 'fresh']
+ budget = [self.args.shrink_budget]
+ deadline = time.monotonic() + self.args.shrink_seconds
+
+ def fails(candidate):
+ if budget[0] <= 0 or time.monotonic() > deadline:
+ return False
+ budget[0] -= 1
+ nonlocal sig
+ f = self.execute([{'op': 'fresh', 'follower': follower}] + candidate, run_dir)
+ if f is None:
+ return False
+ if sig is None:
+ if kind is not None and f.kind != kind:
+ return False
+ sig = f.signature()
+ return True
+ return f.signature() == sig
+
+ if not fails(body):
+ # One more try with the other log mode, then give up.
+ follower = not follower
+ if not fails(body):
+ header['shrink'] = 'did not reproduce on replay'
+ write_ops(path, header, ops)
+ print(f' not reproduced on replay: {path}', flush=True)
+ return
+ small = ddmin(body, fails)
+ # Then shrink each op's payload.
+ small = shrink_payloads(small, fails)
+ header['shrink'] = f'{len(body)} -> {len(small)} ops'
+ out = path.replace('.jsonl', '.min.jsonl')
+ write_ops(out, header, [{'op': 'fresh', 'follower': follower}] + small)
+ print(f' shrunk {len(body)} -> {len(small)} ops: {out}', flush=True)
+
+
+def ddmin(items, fails):
+ """Zeller's delta debugging: a 1-minimal failing subsequence."""
+ n = 2
+ # Trailing ops after the failure never matter; the failing op is last.
+ while len(items) >= 2:
+ size = max(1, len(items) // n)
+ chunks = [items[i:i + size] for i in range(0, len(items), size)]
+ reduced = False
+ for i in range(len(chunks)):
+ complement = [op for j, c in enumerate(chunks) if j != i for op in c]
+ if complement and fails(complement):
+ items, n, reduced = complement, max(n - 1, 2), True
+ break
+ if not reduced:
+ if n >= len(items):
+ break
+ n = min(len(items), n * 2)
+ return items
+
+
+def shrink_payloads(ops, fails):
+ """Halve long data payloads while the failure stays."""
+ ops = [dict(op) for op in ops]
+ for i, op in enumerate(ops):
+ while 'data' in op and len(op['data']) > 8:
+ half = dict(op, data=op['data'][:len(op['data']) // 2])
+ trial = ops[:i] + [half] + ops[i + 1:]
+ if fails(trial):
+ ops[i] = op = half
+ else:
+ break
+ if op.get('chunks') not in (None, 'whole'):
+ whole = dict(op, chunks='whole')
+ if fails(ops[:i] + [whole] + ops[i + 1:]):
+ ops[i] = whole
+ return ops
+
+
+def write_ops(path, header, ops):
+ with open(path, 'w') as f:
+ f.write(json.dumps(header) + '\n')
+ for op in ops:
+ f.write(json.dumps(op) + '\n')
+
+
+def read_ops(path):
+ with open(path) as f:
+ lines = [json.loads(line) for line in f if line.strip()]
+ return lines[0], lines[1:]
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
+ ap.add_argument('binary')
+ ap.add_argument('--seed', type=int, action='append', help='seed (repeatable); default 1')
+ ap.add_argument('--steps', type=int, default=1000)
+ ap.add_argument('--replay', help='run an ops log (or bug record) exactly')
+ ap.add_argument('--shrink', help='shrink a bug record by delta debugging over replays')
+ ap.add_argument('--no-shrink', action='store_true', help='record failures without shrinking them')
+ ap.add_argument('--shrink-budget', type=int, default=300, help='replays a shrink may spend')
+ ap.add_argument('--shrink-seconds', type=float, default=600)
+ ap.add_argument('--smoke', action='store_true', help='fixed seed, few steps, fail on any failure')
+ ap.add_argument('--progress', type=int, default=500)
+ ap.add_argument('--verbose', '-v', action='store_true', help='replay: print each request and log record')
+ ap.add_argument('--out', default=DEFAULT_OUT)
+ args = ap.parse_args()
+ check_served_lines()
+ args.binary = os.path.abspath(args.binary)
+ for key in [k for k in os.environ if k.startswith('PARDES_') or k in ('NINE_MOUNT', 'NAMESPACE')]:
+ del os.environ[key]
+ become_subreaper()
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
+ runner = Runner(args)
+ try:
+ return run_main(runner, args)
+ finally:
+ sweep(f'{os.getpid()}-')
+ for d in RUNTIME_DIRS:
+ shutil.rmtree(d, ignore_errors=True)
+
+
+def run_main(runner, args):
+ if args.replay:
+ header, ops = read_ops(args.replay)
+ run_dir = os.path.join(args.out, 'runs', f'replay-{os.getpid()}')
+ os.makedirs(run_dir, exist_ok=True)
+ f = runner.execute(ops, run_dir, verbose=args.verbose)
+ print(f'replay {args.replay}: ' + (f'FAIL at op {f.step}: {f.kind}: {f.why}' if f else 'passed'))
+ return 1 if f else 0
+ if args.shrink:
+ runner.shrink(args.shrink)
+ return 0
+ if args.smoke:
+ args.no_shrink = True
+ found = runner.fuzz(20260929, args.steps if args.steps != 1000 else 800)
+ return 1 if found else 0
+ total = 0
+ for seed in args.seed or [1]:
+ total += len(runner.fuzz(seed, args.steps))
+ return 1 if total else 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index 6bfe2c90..659e49d1 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1887,"med_us":2147,"p90_us":2277,"max_us":2326},{"op":"render","fixture":"medium","min_us":73,"med_us":76,"p90_us":84,"max_us":96},{"op":"key-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":91,"max_us":173},{"op":"page-down","fixture":"medium","min_us":75,"med_us":78,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":72,"med_us":76,"p90_us":79,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1269,"med_us":1437,"p90_us":1680,"max_us":1697}]}
+{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1841,"med_us":2197,"p90_us":2308,"max_us":2328},{"op":"render","fixture":"medium","min_us":72,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-down","fixture":"medium","min_us":74,"med_us":74,"p90_us":80,"max_us":81},{"op":"key-right","fixture":"medium","min_us":74,"med_us":80,"p90_us":85,"max_us":100},{"op":"page-down","fixture":"medium","min_us":74,"med_us":81,"p90_us":84,"max_us":176},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":79,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1086,"med_us":1515,"p90_us":1692,"max_us":1822},{"op":"body-2m","fixture":"medium","min_us":9044,"med_us":16111,"p90_us":19782,"max_us":21405}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index 6ffd034c..bed59c8c 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1907,"med_us":2037,"p90_us":2283,"max_us":2371},{"op":"render","fixture":"medium","min_us":72,"med_us":80,"p90_us":87,"max_us":87},{"op":"key-down","fixture":"medium","min_us":73,"med_us":78,"p90_us":82,"max_us":88},{"op":"key-right","fixture":"medium","min_us":72,"med_us":75,"p90_us":87,"max_us":87},{"op":"page-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":93,"max_us":94},{"op":"wheel","fixture":"medium","min_us":73,"med_us":75,"p90_us":84,"max_us":88},{"op":"edit-char","fixture":"medium","min_us":1333,"med_us":1712,"p90_us":1956,"max_us":2636}]}
+{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1800,"med_us":2113,"p90_us":2193,"max_us":2326},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":85,"max_us":97},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":82,"max_us":84},{"op":"key-right","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":95},{"op":"page-down","fixture":"medium","min_us":74,"med_us":78,"p90_us":83,"max_us":83},{"op":"wheel","fixture":"medium","min_us":72,"med_us":75,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1027,"med_us":1418,"p90_us":1699,"max_us":1834},{"op":"body-2m","fixture":"medium","min_us":8357,"med_us":17722,"p90_us":20080,"max_us":21699}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index 5d68e6b2..d13442a5 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"67f85bf9692f1778","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1771,"med_us":2022,"p90_us":2188,"max_us":2228},{"op":"render","fixture":"medium","min_us":75,"med_us":76,"p90_us":76,"max_us":86},{"op":"key-down","fixture":"medium","min_us":73,"med_us":79,"p90_us":80,"max_us":80},{"op":"key-right","fixture":"medium","min_us":72,"med_us":78,"p90_us":81,"max_us":82},{"op":"page-down","fixture":"medium","min_us":72,"med_us":76,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":75,"med_us":76,"p90_us":78,"max_us":79},{"op":"edit-char","fixture":"medium","min_us":1106,"med_us":1436,"p90_us":1692,"max_us":1721}]}
+{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1819,"med_us":2002,"p90_us":2068,"max_us":2122},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":73,"med_us":75,"p90_us":76,"max_us":82},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":86,"max_us":126},{"op":"page-down","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":74,"p90_us":76,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1493,"p90_us":1805,"max_us":1967},{"op":"body-2m","fixture":"medium","min_us":8482,"med_us":17227,"p90_us":20296,"max_us":23040}]}
diff --git a/test/perf.zig b/test/perf.zig
index e244091c..c639178f 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -46,6 +46,9 @@ const Op = enum {
page_down,
wheel,
edit_char,
+ /// 2 MB appended to the file through 9P, on one open, in 256 KB
+ /// writes: linear, not a copy of the whole buffer per write.
+ body_write,
fn label(o: Op) []const u8 {
return switch (o) {
@@ -56,10 +59,25 @@ const Op = enum {
.page_down => "page-down",
.wheel => "wheel",
.edit_char => "edit-char",
+ .body_write => "body-2m",
};
}
};
+var body_chunk: [256 * 1024]u8 = @splat('y');
+
+/// The body_write gesture: an open of the pane's body, eight 256 KB writes,
+/// a read (which the held writes go in before), the close.
+fn bodyWrite(core: *pardes.Pardes, serial: u32) void {
+ const Node = pardes.ctlfs.Node;
+ const body = Node.of(serial, .body);
+ for (&body_chunk, 0..) |*c, i| c.* = if (i % 64 == 63) '\n' else 'y';
+ const h = core.serveFs(.{ .tag = 1, .op = .open, .node = body, .omode = 1 }).handle;
+ for (0..8) |_| _ = core.serveFs(.{ .tag = 2, .op = .write, .node = body, .handle = h, .data = &body_chunk });
+ _ = core.serveFs(.{ .tag = 3, .op = .getattr, .node = body, .handle = h });
+ _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true });
+}
+
const TermFixture = struct {
name: []const u8,
kb: usize,
@@ -514,12 +532,14 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
.page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
.wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }),
.edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
+ .body_write => bodyWrite(core, pane.serial),
}
pump(core);
_ = try frame(core);
const dt = nowNs() -| t0;
switch (op) {
.open => unreachable,
+ .body_write => if (pane.file.?.content.len != before.len + 8 * body_chunk.len) return error.BodyWriteWasNotAppended,
.render => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len)
return error.RenderChangedDocument,
.key_down => if (pane.body.cur_row != before.row + 1) return error.DownDidNotMove,