summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/cloud9.md139
-rw-r--r--docs/config.md790
-rw-r--r--docs/detached.md76
-rw-r--r--docs/divergences.md106
-rw-r--r--docs/fs.md1689
-rw-r--r--docs/open-questions.md83
-rw-r--r--docs/tags.md408
-rw-r--r--docs/v9fs.md130
8 files changed, 1006 insertions, 2415 deletions
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..6560368c 100644
--- a/docs/divergences.md
+++ b/docs/divergences.md
@@ -1,94 +1,26 @@
# 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"
-```
+`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.
-* **`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`.
+## Known wrong on `main`
-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.
+- `Dump` of a session with more than 6 columns fails `Dump: bad dump
+ columns` (`src/dump.zig` `max_cols` is 6; a session holds 16).
+- `pardes FILE` in a pane forwards only a FILE that exists; a new file name
+ starts a separate editor instead of opening a pane in the session.
+- `Dump` fails `no such directory` when the default `DumpDir`'s parent
+ (`$XDG_DATA_HOME`, or `~/.local/share`) does not exist.
diff --git a/docs/fs.md b/docs/fs.md
index ca02082b..e4750690 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -1,1142 +1,659 @@
# 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
-A forwarded launch returns at once, as acme's `B` does. `--wait` (`-w`)
-returns only once the pane the file landed in -- the one already showing
-it, if any -- is deleted (exit 0), or the session goes away (exit 1), as
-acme's `E` does: what an `$EDITOR` must do, since fish's Ctrl-O
-(`edit_command_buffer`), `git commit` and `crontab -e` read the file back
-when the editor exits. Set `EDITOR='pardes --wait'`; `GIT_EDITOR` follows
-`EDITOR` when unset. Outside pardes `--wait` changes nothing, a session
-blocking anyway.
+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)).
-Look resolves the OS filesystem first, then the editor's virtual filesystem.
-Explicit paths bypass that search:
+Pane shells get `PARDES_PID` (the editor's pid), `PARDES_9P` (the socket)
+and `PARDES_PANE` (their pane's serial).
-| 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` |
+**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`.
-The mount name `self` is reserved and maps to the server root, so `/n/self/X`
-and `/virtual/X` both name the served `/X`.
+`--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.
-`--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.
+**Clients.**
-`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.
+```sh
+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
+```
-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.
+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)).
-[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:
+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.
-```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"'
-```
+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`.
+
+**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.
+
+**Look paths and mounts.** Look resolves the OS filesystem first, then the
+editor's own tree. Explicit paths skip that search:
-(Under `9ns --unix`, `$NINE_MOUNT` is that session's root itself.)
+| 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` |
-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).
+`--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.
-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.
+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.
-## The served tree
+## The tree
```
-/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; the root's Newcol halves the active column,
- so reaching 16 means running Newcol from the widest column's tag),
- 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
+/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)
```
-`/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`.
+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.
-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>`. Undo history does not survive a Restore: a restored pane
-starts with none. 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); acme's own words run as
-pardes's where it has one -- `Put` is `Save`, `Delete` a `Del` that does
-not ask -- and the rest (`Get`, `Putall`, `Snarf`, `Cut`, `Paste`, `Zerox`,
-`Sort`, `Load`, `ID`, `Send`) are refused, EINVAL, `invalid: acme's Snarf
-is not a pardes builtin`, never run as shell commands; 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`; acme's other ctl words are taken
-too, done by the file or builtin that replaces each: `name x` (the `name`
-file), `put` (`Save`), `clean` and `dirty` (`dirty`), `del` (`Del`),
-`delete` (a `Del` that does not ask), `dot=addr`, `addr=dot`,
-`limit=addr`, `mark`, `nomark` (`mark`), `show` and `cleartag`; `dump`,
-`dumpdir`, `font`, `menu` and `nomenu` are refused with why, EINVAL. 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.
+`/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`.
-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.
+## Rules for every file
-`/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.
+**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`, `Modified` or
+`tag: over 4096 bytes`, EIO. The `err` record has the words; a shell sees
+only the errno, most often `Invalid argument` or `Input/output error`.
-```
-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.
-```
+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
-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.
+`/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.
-`/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`.
+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`.
-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.
+`/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>`.
-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.
+`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: over 4096 bytes`, EIO). A clear is an ordinary edit
+and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.
-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.
+## Panes
-`/look` and `/exec` are the editor's two clicks, one per line of a write:
+**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 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.
+**`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.
-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.
+**`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`).
-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.
+**`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).
-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<&-`.
+**`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.
-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. A write that fills its piece may go on in the next, so
-the open keeps its last line with no newline until then; a write shorter
-than a piece is the whole of what was written, as acme takes each write, so
-its last line runs with it even with no newline (`printf Save > exec`), and
-a failure is that write's. Only what needs more is held: an Edit block
-whose `{` or `a`/`c`/`i` text has not ended waits for its next write, and
-what is left when the file closes runs at the close -- 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).
+**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:
-`@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.
+- 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.
-`/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, applied once however the writes cut it; a write with
-no newline that is whole in its Twrite is the name then, so a bad one fails
-that write, not the close. 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, leaving dot
-just past the text written (a `data` write moves dot as its text moves it,
-so a dot at the address it wrote at ends just past that text too). `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, field for
-field as plan9port's — serial, tag length, body length, isdir (0), the dirty
-flag, the width in cells, the font, the tab width, whether Undo has a step
-(1) and whether Redo has one — followed by pardes's own: rio's `current` or
-`notcurrent` (rio(4), `wctl`), whether the pane has the keyboard, and a
-REPL's id. 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>&-`.
+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.
-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).
+### Addresses and data
-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).
+`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.
-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`.
+- 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.
-`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:
+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.
- 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
+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.
-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`.
+Refusals: `bad address syntax`, `no match for regexp`, `address out of
+range`, `addresses out of order` (`#100,#50`), `bad regular expression`.
-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 (`$` on a CRLF line sits before the `\r`,
-as the line's end is its `\r\n`), 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. A pattern may be up to 512 of mvzr's operations,
-about 512 characters (mvzr's own is 64; pardes builds it with more); a
-longer one is refused, `bad regular expression: longer than mvzr's 512
-operations`. A class may hold non-ASCII runes (`[éa-z]`, `[à-ÿ]`): it is
-taken as an alternation of them (`(é|[a-z])`), a range of up to 256 runes
-spelled out, and the 512 counts the pattern as rewritten. A wider range
-(`a range of runes wider than 256 in [...] is not supported`) and a negated
-class with a non-ASCII rune (`[^é]`) are refused, since mvzr's classes hold
-bytes. 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 took too much time,
-gave up` rather
-than answer a match it is not sure of. The budget is each search's: an
-Edit `x` over 100k lines makes 100k searches, each with its own. 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.
+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.
-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.
+### Regular expressions
-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. An open's writes in a row to one place -- an
-append to `body`, inserts going on at `data`'s address -- are held and go
-in as one edit when anything else comes (another request, the close, or
-the editor's step once they pause 20 ms), so a 10 MB write is one copy, not
-one per 8 KB piece; any read sees them.
+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:
-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. The rule is a write's:
-a refused open or truncation -- an OTRUNC open, such as `data`'s after a
-failed `addr` --, create or remove answers its error and
-logs no `err`, and so do a write to `pane/new` (`permission denied`: it is
-only read) and a write on a fid opened OREAD (`bad use of fid`). Every
-refusal is said in words, Plan 9's where pardes has none of its own
-(`permission denied`, `file does not exist`, `bad argument`), never a C
-library string such as `Operation not permitted`. What is not a
-failure: a look that finds nothing answers nothing and logs one `err`
-(`look: no match for ...`, quoting what was written in every form --
-`no match for "zzq:#3"`, `no match for "zzq:2"` -- 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.
+- 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. The editable text is 4096 bytes at most: a
-write that would pass that is refused whole, ENOSPC, `tag: over 4096
-bytes`. 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: with a follower attached, a repeat
-is a new line carrying its `(xN)` count, never an edit of the one read. 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. One line a run: two lines in one write, or a next line on
-the open before the last answer is read (bash writes `printf 'a\nb\n'` a
-line at a time), are refused, EINVAL. 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 (a command pane's child is its command, which does not
-restart: `exec` there is refused, EINVAL, `a command pane does not
-restart`): 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 |
+| a dump | 6 columns (so `Dump` of more fails `bad dump columns`) |
+| 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 7671e056..8fe63804 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -1,313 +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), but changes nothing else in refusing -- no `+Unsaved`
-opens, no focus moves; its `unsaved` records and notice say which. 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.