summaryrefslogtreecommitdiff
path: root/docs
Commit message (Collapse)AuthorAge
* macos: use two-finger click for Exec and deep press for LookGabriel Schneider2026-09-07
|
* hosts: the effects three shells kept a copy of become one, and the mac's own ↵Gabriel Schneider2026-09-02
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | bugs go with them Nine read-only scouts compared every host-side concern across `src/macos.zig`, `src/tty/tty.zig`, `src/gui/gui.zig` and `src/detached/server.zig`. What they found was not a style problem: each duplicated body had drifted, and in every case the drift WAS a bug the users of that shell could see. So the fixes and the deduplication are the same change. **One PATH, adopted before the first fork.** LaunchServices hands a bundle launchd's environment, whose `PATH` is `/usr/bin:/bin:/usr/sbin:/sbin`. Every pty shell, `|` filter and language server the app forked inherited it, so `yazi` in `/opt/homebrew/bin` was absent from a Dock launch and present in the identical binary run from a terminal — the "it worked briefly" window was simply the sessions started from a shell. `shell_bin.adoptSystemPath` composes `/etc/paths` then `/etc/paths.d/*` in the order `path_helper` reads them, deduplicating on first occurrence, and runs once at startup in all four native hosts. It APPENDS: an entry already present keeps its position, so running it over a real session cannot demote a mise shim behind `/usr/bin` and silently change which `node` runs. A `PATH` that was configured is left byte-for-byte alone; only one nobody configured is repaired. `prepareForFork` folds that adoption together with the prompt-rc staging and the `BASH_SILENCE_DEPRECATION_WARNING` setenv the five hand-copied prefork sites had between them — `server.zig` had none of it, which is why every detached pane opened with Apple's zsh banner. **The LSP protocol client never worked on macOS.** It opened its control socket with `libc.SOCK.CLOEXEC`; Zig defines that constant for Linux and Darwin answers `socketpair` with `EPROTONOSUPPORT`, so the call failed before any fork, `ensure` returned `error.NoServer`, and every row in the spec table — rust-analyzer, clangd, gopls — was unreachable in every macOS build. The in-process ZLS backend kept answering, which is what made it read as "only Zig is supported". It is a plain socket plus `fcntl(FD_CLOEXEC)` now, the route `fuse.zig:943` and `nested.zig:95` already took for the same reason. The snapshot suite that covered this path had never run natively on a Mac: the harness targets defaulted to x86_64-linux. **One LSP host worker.** `src/lsp_host.zig` is the snapshot, the worker body and the job lifetime that `tty.zig` and `gui.zig` carried verbatim — `gui.zig` said so in a comment — and that `macos.zig` did not carry at all: `lsp` and `pipe` were absent from its `Host.VTable`, so the core answered its own empty answer, `SPC l i` rendered a blank panel and a `|` filter silently did nothing. All three shells share the module, and the AppKit host implements both effects. Its status sink is now REGISTERED as well as defined, so unsolicited server news reaches the message row instead of nowhere. **The animation clock measures time.** `pardes_animation_tick` advanced one scene frame per callback and published `frame_count / 60`, so scene time was a count of callbacks rather than elapsed seconds — and `AppDelegate` re-armed `asyncAfter(.now() + 0.016)` only after the previous frame's work had finished, making the true period 16 ms plus all of it. Motion ran at about three quarters of wall clock and unevenly. The tick now spends measured monotonic time in whole `frame_ns` steps and banks the remainder, so a late callback advances two frames instead of stretching one; `spendTickTime` is that arithmetic as a pure function with its own tests and no display attached. On macOS 14+ the animating run is one `CADisplayLink` phase-locked to vsync rather than a chain rebuilt after every frame; macOS 13 keeps the old chain. **Three more single definitions.** `panel_animation.paintOrder` is the moving-then-opening-then-closing composite order as a rule the core applies once in `Pardes.render` — `macos.zig` was re-sorting an already-sorted list. `selection_pipe.Tasks` is the bounded in-flight pipe table `tty.zig` and `gui.zig` each declared. `boxContains` was a fourth copy of the half-open cell test and is now an alias of `Box.contains`. **A filtered terminal stops asking libm per cell.** `Filter`'s legibility stage called `RGB.contrast` for every painted cell, and that ends in `std.math.pow` up to six times, re-deriving a ratio against a background that had not moved; the existing memo cache covered the palette reduction beside it and never this. The indexed path's input is a `u8`, so all 256 answers are enumerated once per pass — after the default roles are fixed, before the first cell is read — and what a cell names becomes an array index. Only truecolour still reduces. ReleaseFast, 190x56, Tracy: recolour 3.09 ms -> 0.130 ms, frame 3.37 ms -> 0.299 ms. The comptime luminance table is pinned to `RGB.luminance` and `RGB.contrast` by exact-equality test over every channel value and all 65 536 palette pairs, because the decision is a threshold comparison where one ULP is a different colour. A `filterInit` Tracy zone records the part that is still per-pass: 2.9 us warm against a 117 us pass, which is the measurement that says not to cache it across frames. Released as 0.0.2. `build.zig.zon` carries the version into `pardes --version` and into the `Changelog` pane through `@embedFile`, so the entries above open a `## 0.0.2` section and `## 0.0.1` closes with the tagline work of the parent commit. Two bugs here were mine, caught by review rather than by me: a double free in the macOS pipe drain arm (`Msg.free` already owns the response) that segfaulted the app on the first `|`, and a proposed `getRowAndCell` optimisation that targeted 2 of 43 draw samples while the contrast math beside it took 12 — and would not have compiled. The profile that justified it was a Debug build, which `build.zig:1160` already documents as ~5x slower than release. Native and -Dplatform=macos suites: 0 failures. All targets build with Tracy on and off; the shipped release binary contains no `___tracy_emit_zone_begin`. App reinstalled, signature verified, dmg regenerated, launched with 0 crash reports; installed binaries verified byte-identical to a fresh build.
* macos: one tagline rule for both hosts, a kqueue beside the inotify, and ↵Gabriel Schneider2026-09-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | effects that compile Three things this shell had its own copy of, and in each case the fix is that it stops having one. **The tagline band.** A pane tag draws at `gui_tagline_font_percent` of the body face and the band it sits on shrinks with it, while the grid row stays body-sized — so something has to decide where the shorter band sits in the taller row. This shell decided by centring, always, which is precisely the case `config.gui_topbar_pane_border_px` exists to prevent: the topbar's unused half-band meets the first pane tag's unused half-band and the window background shows through the seam. The strip is as wide as the bands are short — on a 20-pixel cell, 4 physical pixels at the default 82%, 10 at 50%, 14 at 30% — so it grew as the tagline face shrank and read as "the tagline is wrong on the mac" rather than as one missing rule. The rule is `pardes.taglineBandOffset` in the core now and both pixel hosts call it: row zero bottom-aligned, the first pane-tag row top-aligned, the two joined by `gui_topbar_pane_border_px` in the theme's scrollbar-track colour, every row between centred, and a `Tagbottom` band on the final row flush with the window edge — with the sub-cell strip beneath it painted in that band's own colour, because the core grid holds only whole cells and a window is any height it likes. `pardes_tagline_band_offset`, `pardes_topbar_pane_border_px` and `pardes_topbar_pane_border_rgb` carry it over the C ABI as PHYSICAL pixels: the host multiplies its points by the backing scale going in and divides coming out, which is the snapping `Metrics` already does for the cell, and is what keeps a one-pixel rule one pixel instead of a two-pixel smear. **The watch.** `file_watch.zig` was one mark/reconcile transaction over `inotify`, so the tty shell, the SDL window and the detached daemon all watched nothing off Linux: an edit made outside pardes never reached the pane, and a PDF replaced on disk kept rendering the old inode. It is the same transaction over two kernels now — `init`, `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are still the whole of it, and the hosts wait on a kqueue and poll it exactly as they did the old descriptor. A macOS mark is TWO filters, because a kqueue directory filter reports its entries changing and never a write to a file already inside it: the parent mark follows rename-over saves, `markFile` catches in-place writes, and `remarkFile` re-arms the file filter once a rename has moved the inode. That is the same pair the AppKit host's DispatchSources already used for the same reason. Directory marks are deduplicated here by device and inode, because each `EVFILT_VNODE` filter needs a descriptor of its own and inotify did that deduplication itself; `stop` and `drain` wake through the one `EVFILT_USER` filter, since a kqueue cannot simply be read the way an inotify descriptor can. **The effects.** The three `crt.ci.metal` entry points are `extern "C" [[stitchable]]`. `CIKernel.kernels(withMetalString:)` compiles that source at runtime, looks for stitchable functions, and rejects the WHOLE source with "cannot find a valid stitchable Metal function in the source" when it finds none — so `ScenePostprocessor.init?` returned nil and every scene effect and panel transition silently degraded to the plain CoreText draw. The `effect_sources.zig` test pins the exact spelling of all three, and `draw-effect` in the e2e suite catches the degradation rather than the spelling. Beside them, the offscreen harness owes the core a PRESENTATION. Its window is borderless and never ordered front, so AppKit runs no display cycle and `pardes_frame_presented` — whose only caller is `draw(_:)` — never fired. The core holds pointer gestures inert while a layout mutation has not reached a backend, which for an unpresenting harness is the rest of the script: the first pane a script opened silently killed every later click, drag and Look. So `readFrame` presents what it just rendered, into a bitmap nobody reads. `PARDES_CHROME` also looks under `/Applications`, where a browser's executable lives inside an application bundle and never on `PATH`. The macOS goldens are regenerated; docs/macos.md, config.md, detached.md, web.md and the design PDF follow.
* lsp: a protocol client for every other language, narrated on the message rowGabriel Schneider2026-09-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum.
* build: pardes builds without the ESP32-P4 toolchain checkout beside itGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `build.zig.zon` named `../05-zig-p4` as a path dependency, and `build.zig` `@import`ed it inside `if (-Desp32p4-firmware)`. But `@import` in a build script is resolved when the SCRIPT is compiled, not when the branch that needs it is taken -- so naming the package at all meant anyone without that sibling checkout could not build pardes AT ALL. Not the firmware: the terminal shell, the SDL shell, the tests. `zig build` failed with build.zig:1073: error: no module named 'zig_p4' available within module 'root.@build' from a line inside an `if` that was false. Neither escape hatch works for a PATH dependency, and both were tried rather than assumed. `.lazy = true` is about FETCHING; a path dep whose directory is absent is generated as a package with no `build.zig` rather than one marked unavailable, so `b.lazyImport` -- which exists for exactly this and is what the standard library says is to `@import` what `lazyDependency` is to `dependency` -- reaches a `@compileError` instead of returning null. Making it a fetched dependency instead is not available either: the toolchain has no remote. So the duplicate goes. That build tree's firmware block linked an image the toolchain repository already knows how to link -- its own build.zig has `-Dpardes`, `-Dapp=<root>` and `-Dpardes-obj=<path>`, and its comments record having learned this same lesson from the other direction, where nesting pardes's ~30-package graph under it broke every build there. The object is the seam: it crosses by PATH and never by package, and each repository builds what it owns the pieces of. zig build -Dplatform=esp32p4 # here, no toolchain needed zig build -Dpardes # there, the console image zig build -Dpardes -Dapp=<pardes>/src/esp32p4_9p.zig # there, the 9P image For the second and third to work with no module map, `src/board9p.zig` and the 9P firmware root now reach the codec by PATH instead of through a named `ninep` module that only pardes's own build.zig knew to inject -- which is also why the root moved from `src/esp32p4/nine.zig` up to `src/esp32p4_9p.zig`, beside `src/esp32p4.zig`: a path import may not escape its module's own directory. Both files are now self-contained, and `zig test src/board9p.zig` works with no flags. `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` go with the block. An option this build cannot honour is worse than no option, because it accepts the flag and then ignores it; all three are spelled the same way in the toolchain. Verified by moving ../05-zig-p4 out of the way: `zig build`, `zig build -Dplatform=esp32p4` and `zig build unit-test` all pass without it. With it back, the toolchain still links both images -- console 812,720 B, 9P 88,096 B. next-steps.txt gains the six features the 9P chain shipped.
* 9p: the client half, and a board that serves its own tree over the UARTGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 5 of the 9P chain (docs/9p.typ 12.5, docs/registry.typ 9P-22, 9P-11, BOARD-1). THE CLIENT. `Client` in src/9p.zig is the mirror of `Server` and the same shape: sans-io, no allocator, no threads, no descriptor, caller-owned buffers, and it builds freestanding. 152 bytes of struct against the server's 9,488, because a client owns neither a fid table nor a park table -- the far end does. The API is submit / push+output+wrote / take. Completion is a PULL: a callback would fire inside push, inside the transport's read, inside the host's poll dispatch, which is exactly where fs9_service says filesystem work must not happen. `take()` returns the next completed operation or null, which is `Server.next()`'s loop-until-null contract read from the other side. Tags are a fixed 16-entry table indexed BY the tag, so an out-of-order reply -- which 9P allows and both reference clients rely on -- costs one bounds check. The reply's TYPE is checked against the request's op, because a tag is only as good as the table behind it. A `Done` borrows the input buffer and is valid until the next call; `take()` releases the previous frame on entry, so the rule is mechanical rather than remembered, and read data and error strings are zero-copy. And one real caller, so this is not a library with no user: the `9p` word takes a dial and a path, walks another instance's tree, and opens the bytes in a pane like any other `Look`. THE BOARD. A SECOND image, not a second role: the console runtime keeps UART0 bidirectionally and is behaviourally untouched. On the new one the UART carries 9P AND NOTHING ELSE -- no ANSI, no vaxis, no allocator, no heap module. The loop is uart.read -> push / retry+next -> handle -> reply / output -> writeSome -> wrote. `writeSome` is new and additive: `write`'s bounded spin DROPS bytes on a stalled transmitter, which on a protocol stream truncates a reply mid-message and desynchronises for good, where a short count cannot. BOARD-1's one divider write raises the line to 921600. 88,000 B text, 49,424 B bss, an 88,080-byte image -- 5.7% of the 1,536,000 B partition, against the console image's 809,536 B. THE COMPTIME BRIDGE, which is the part worth reading. `board9p.caps` is the ONLY place the GPIO tree is described; node ids, parents, names, permissions, handlers, buffer size and the per-pin directories are all derived from it, and `fan.dirs` makes `gpio/<n>/value` one table entry serving eleven pins. Modes are derived from which handlers a file has rather than declared. A second capability is a table entry, not new tree code. JP1 became a real table in the new leaf `src/board_pins.zig`, with the ASCII drawing RENDERED from it at comptime and the pin list COLLECTED from it -- the 9P image links no core and so cannot import board_memory.zig, and copying the table was not acceptable. A golden test pins the drawing byte for byte, the console's own shape test still passes, and the identical bytes are present in all three artifacts. PROVED. Two daemons: B read A's `/1/body` through the `9p` word into a pane, byte-identical to plan9port's `9p read` of the same path. Both board images build. No hardware was attached, so nothing about the board is claimed beyond what builds and what the host tests cover. zig build unit-test 585/585. fs-bench unchanged and still zero allocations on every read row. --- REVIEW FIXES FOLDED IN. Steps 3, 4 and 5 were verified on the happy path and then adversarially reviewed by three agents; eight defects, six fixed here, five of them reproduced with measurements before and after. Full writeup in docs/registry.typ `9P-27`. In brief: * a remote crash of the WHOLE daemon: one `size[4]` of zero plus one byte hit `unreachable` in `fs9_service.fill`. Also 99.7% of a core when the stuck buffer made `room == 0` return without reading. Now `srv.dead` is a hangup, checked before the room guard. * the editor froze 177 s on a dial: `connect(2)` ran on a still-BLOCKING socket before the deadline existed, and a full accept backlog waits forever. Now non-blocking with the wait spent against the budget. After: 2.03 s. * a 64 KiB pty read is exactly `queue_cap` and wiped every unread byte AND dropped itself. `notePtyOutput` splits at half the cap. Deterministic. * four silent sockets denied `--fs9` forever; connections now expire on the same five-second rule the frontend transport already had. * EMFILE spun a core; the listener pauses and leaves the poll set, as the frontend listener does. * `max_fids = 32` made `find` over `9pfuse` fail with 57 consecutive `Rerror`s -- refuting this step's own acceptance clause. 256 for a host, `board_fids` 32 for the microcontroller. Found clean and worth recording: `sig` reaches the foreground process group; the two-namespace pty lookup is right over both transports; `PaneFile`'s u4 wall is guarded; reader counts release on every abrupt-death path; `fs_origin` routing and the reply arithmetic hold under probing.
* 9p: serve the acme tree over 9P2000 on a unix socket, beside the mountGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 4 of the 9P chain (docs/9p.typ 12.4, docs/registry.typ 9P-15/16/17/4/5). src/9p.zig is a base 9P2000 codec and a SANS-IO server: it never touches a descriptor, takes no allocator, starts no thread, and builds for wasm32-freestanding and riscv32-freestanding. That is what lets the same code serve a unix socket here and a UART on the board later. Server(comptime fs: type) duck-typed on fs.Req/fs.Reply/fs.Reply.Attr, so it never imports acmefs and acmefs never learns 9P init{ in, out, root } the caller owns the buffers; msize is derived retry/next/reply the three fs_service.Transport ops, by name push/output/wrote/hangup bytes in, bytes out, partial writes supported next() is a PUMP, not one-message-one-request: a 3-element Twalk is three lookups, Topen|OTRUNC is a setattr then an open, Tversion is none at all. Decisions that were open and are now taken, each recorded in the file: * qid.version is ALWAYS 0, which makes Linux set P9L_DIRECT and skip its cache -- the 9P equivalent of the FOPEN_DIRECT_IO fuse.zig relies on. * Every Rread is clamped to the client's count. An over-long one is a hard -EIO in Linux, not a truncation. * Rerror carries Linux's exact strerror text (registry 9P-4 option A), so a mount recovers the errno instead of ESERVERFAULT. Asserted as literals, because a typo there is 'Unknown error 526' on every mount. * `.` and `..` are resolved BY THE SERVER. Under FUSE the kernel does it and acmefs says so; 9P has no kernel, and forwarding `..` as a lookup would break every client that normalises a path. * Topen checks the perm bits itself. Under FUSE the kernel enforced them; over 9P nobody is above the server, and `errors` would have been readable. * Tcreate and Tremove are Rerror: `new/` creates a pane on WALK, so the capability exists and is not spelled Tcreate. THE INTEGRATION BUG, which was not in the protocol: the daemon's push_fs_reply sent every reply to the FUSE mount, whose park table has no 9P tag, so it dropped it -- Tversion worked (no core involved) and Tattach hung forever. That is exactly the 'no routing origin for the 9P descriptor' cell in the layering table of docs/9p.typ. Session.fs_origin now carries the transport that asked. Proved with plan9port against a live daemon serving BOTH transports at once: 9p ls / and /1, read index/ctl/tag, write /1/body, stat, a walk through /1/../index, pane creation through `new/body`, and the two refusals arriving as strings -- 'permission denied' and 'No such file or directory' -- confirmed on the raw wire as Rerror text rather than numbers. A write over 9P reads back through FUSE and a write through FUSE reads back over 9P. msize 8192, 34,072 bytes per connection (Server 9,488 + in 8,192 + out 16,384, out being two msize so that every reply is infallible), four connections. zig build unit-test: 468 tests before, 503 after.
* detached: drop /dev/fuse from the poll set once the connection diesGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Review fixes to steps 1 and 2, found by an adversarial pass over the committed chain. One is a real bug and the rest are comments that were false. THE BUG. `waitInput` put the FUSE descriptor in the poll set whenever the mount existed, and the `.fuse` arm ignored every revent. Linux's `fuse_dev_poll` answers EPOLLERR once the connection is gone, and POSIX reports POLLERR whatever the events mask asked for -- so an external `fusermount3 -u`, a sysfs abort, or systemd taking /run/user/$UID away at final logout (exactly when a detached session is supposed to keep running) made poll(2) return instantly, forever. The daemon then spun the whole pump at 100% of a core for the rest of its life, and re-offered every parked slot to the core at that rate. Measured on the unfixed commit: 0 CPU ticks over 10 s idle, then 1000 ticks over the next 10 s after unmounting its own mount point. Measured after the fix: 0 ticks over 8 s in the same scenario, process alive and in state S. fuse.zig's own poll thread has carried the equivalent guard all along, which is why the desktop shells never showed this and the daemon did. THE COMMENTS, each checkable and each wrong: * `Source.fuse` said the drain is not done in `dispatch` to avoid re-entering the core. Both `pull_wait_input` and `push_poll_frame` are called from inside `pump`, so either re-enters. The real reason is that `dispatch` is mid-iteration over a SNAPSHOT of the descriptors, and one `push_spawn` replaces a pane's master under it. * `fuse.zig`'s new import said "no cycle". There is a cycle: fs_service imports fuse.zig back and still names `*Fs` in three signatures. * `Transport`'s rationale said tty.zig and gui.zig store one in a struct field. Neither does; all four call sites build it inline. * `deinit` justified its ordering against "as long as the harvest takes". `harvest` is waitpid(WNOHANG) and blocks for nothing. The real reason to go first is that aborting the connection wakes a parked reader while its own shell is still alive to run its exit path. * `harvest` claimed pane shells are the only children this process forks. Since step 1 it also forks `fusermount3`, three times over -- reaped by its own spawner, so the conclusion holds and the premise did not. * docs/detached.md, docs/lsp.md and docs/design.typ still said the daemon implements sixteen of twenty-one methods and mounts no /dev/fuse.
* acmefs: a pane's terminal gets pty/data, pty/ctl and pty/statusGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Step 3 of the 9P chain (docs/9p.typ 12.3, docs/registry.typ 9P-8). Nothing here is about 9P: it lands in the FUSE-served tree and any later transport inherits it. A script could write into a terminal that already existed and read its rendered scrollback. It could not START one, RESIZE one or SIGNAL one. Two of those were already effects the core emits, so `exec` and `winsize` are existing capabilities acquiring a name; only `sig` is new, and it brings the one new host method, `push_pty_signal`. pty/ctl winsize <cols> <rows> | sig INT|TERM|HUP|QUIT|KILL | exec one verb per line, validate-all then apply-all, EINVAL applies nothing -- `writeCtl`'s shape and `writeCtl`'s reason pty/status cols, rows, tty-taken as three %11d fields pty/data write is input to the process; read is the RAW output stream, gated on a reader count so a pane nobody reads costs one branch A pane that is not a terminal has no pty/ at all: the lookup is ENOENT and readdir does not list it. `PaneFile` is an enum(u4) and this takes it from 11 values to 15. ONE REMAINS. That is also why pty/ is a DIRECTORY and not three more flat names -- a subdirectory costs one value and buys its own namespace, so `ctl` and `data` did not have to be renamed. Two things the core does not know, and which are therefore not invented: a child's EXIT STATUS (a shell's death is `Event.eof`, which removes the pane, so there is no directory left to read it in) and RAW/COOKED (the core never sets a termios; the mode belongs to the program on the far side). Verified live against a daemon: pty/ appears only on the terminal pane; a `winsize 0 24` and a `sig SIGINT` are refused; a bad verb beside a good one applies neither; `echo pty-works` written to pty/data runs in the shell and its output reaches the body; and a blocking read of pty/data returns the raw stream, OSC 133 marks and all. fs-bench unchanged and still zero allocations.
* docs: a 9P design note and a design registry to argue it inGabriel Schneider2026-08-27
|
* An edited row keeps its colours, four copies of forkShell become one, and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc stops recentring ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
* tests: a capture is a delta and a click names its word, so a tagline edit ↵Gabriel Schneider2026-08-25
| | | | stops rewriting the suite
* acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSEGabriel Schneider2026-08-25
|
* builtins + config: Save reaches every pane holding text of its own, and ↵Gabriel Schneider2026-08-25
| | | | takes a path argument
* syntax + pdf_pane + file_pane: prose grammars paint, pdf Esc cancels, gj/gk ↵Gabriel Schneider2026-08-25
| | | | walk wrapped rows
* host: the core owns the event loop; every platform becomes a vtable of ↵Gabriel Schneider2026-08-25
| | | | optional methods
* animation: six character-motion panel transitions, composed in the core so ↵Gabriel Schneider2026-08-25
| | | | backends agree
* gui: rendering rework, web css, config doc trimGabriel Schneider2026-08-18
|
* term_pane + builtins: terminal pane work, builtins/config additions, snapshotsGabriel Schneider2026-08-18
|
* nested + pardes: snapshot updates and small behavior fixes across panesGabriel Schneider2026-08-18
|
* file_watch + builtins + config: richer watch semantics, new builtins, config ↵Gabriel Schneider2026-08-18
| | | | docs
* modal + file_pane: rework editing math and pane behavior, config/syntax ↵Gabriel Schneider2026-08-18
| | | | additions, unit tests
* animation: core publishes transition records; gui evaluates via shaders, tty ↵Gabriel Schneider2026-08-18
| | | | over grid cells
* big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web ↵Gabriel Schneider2026-08-18
| | | | + snapshot refresh
* look: richer path/range parsing, pdf rendering, corner-drag and stepgrain ↵Gabriel Schneider2026-08-15
| | | | snapshots
* shaders: -Dprebuilt-shaders, so a gui build needs no Vulkan SDKGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | glslc is the one build input that wants a tool a stock machine does not have, and it is also the input that changes least often: eight GLSL files that have outlived several rewrites of everything around them. Asking every machine that wants to run the SDL shell for shaderc is the wrong trade. The SPIR-V is now COMMITTED, under shaders/prebuilt/, and -Dprebuilt-shaders embeds that copy instead of shelling out. The default stays the honest one -- compile the shaders that are actually in the tree -- because the flag trades a dependency for a freshness problem: with it on, the .glsl sources are not build inputs at all, so editing one changes nothing. `zig build shaders` is the other half, and it is deliberately independent of -Dplatform: it recompiles every shader and writes the result back into the tracked directory, so whoever changes a shader refreshes the cache on a machine that has the compiler and commits the diff. `jj diff shaders/prebuilt` after it is the freshness check -- empty means the cache was already current. The shader list is also spelled once now (gui_shaders): the eight embeds, the eight glslc runs and the refresh step all read it, so adding a shader is a name there plus the @embedFile in gui.zig, not three edits in two places. Verified: -Dplatform=gui -Dprebuilt-shaders builds with glslc absent from PATH, and image-harness passes on that binary -- real SDL GPU pipelines built from the committed SPIR-V, 512 source pixels read back. The default gui build still runs the eight glslc steps; tty runs none. The committed bytes are identical to a fresh glslc run, and `zig build shaders` is idempotent.
* docs: the tutor taught three keystrokes wrong, and the rest had driftedGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The documentation had gone stale in the ordinary way -- claims that were true when they were written and that nothing since had been obliged to re-read. Some of them were load-bearing. THE TUTOR. It still said there is no multi-cursor, that NextColor cycles three themes, and that its practice blocks "are also run as unit tests (generated from this file by tutor_gen)" -- a tool that appears nowhere in the tree, and nothing anywhere parses a `# keys:` block. Left alone, that claim is what makes the next wrong block survive. Three of those blocks WERE wrong, and all three for one reason: since the helix motion model landed, w/e/f/t SELECT the range they cross, so `i` after one inserts at the SELECTION'S START. `w i Z esc` on "foo bar" gives "Zfoo bar", not the "foo Zbar" the file promised. They were written against a vim reading of the same keys. Every block in the file has now been run through `zig build hxdiff` against the real core and matches byte for byte, and the trap itself is written down in 3.3 rather than left to be rediscovered. The tutor gains a PART 4 for everything added since it was written -- PDF panes, the in-process ZLS backend, themes and fonts, the startup file -- and PART 3 gains counts (and which keys ignore one), f/F/t/T, the whole g table (bare `G` is a no-op; `ge` is the START of the last line), multiple cursors and the s/S regex pair, `m`, `]`/`[`, `|`, insert mode, and all fifty leader paths. THE REST. design.typ's line table claimed 7,626 lines against a real 38,048, and its rows did not sum to its own total; its Event/Effect boundary contract -- the part a shell author writes against -- named four variants that do not exist and omitted fourteen that do. lsp.md's probe count. config.md's theme-name rules, which as written could not reach a zed theme at all. helix-keys.md's Skipped section, holding five families that have since landed. macos.md's menu bar, undocumented, along with sixteen other claims. web.md on what the browser build can actually do. SOURCE COMMENTS that had rotted alongside them: `tag_normal` is a space, not the `•` its own comment describes; Wrap is ON by default, not off; a FontSel row is SELECTED by n and RUN by Tab, not run by n; the SPC paths in lsp.zig lost their `l` group prefix when the language group moved; and the differential suites are 481 and 561 cases, not 360 and 440. TWO THINGS FOUND BY DOCUMENTING THEM, both left standing and written down rather than papered over. Typing `[^\n]` at an s/S prompt panics: the live preview compiles every prefix, and `[^\` indexes an empty slice in mvzr's parseCharSet. Both the tutor and a waiver recommended that pattern as the workaround for `.` matching a newline; they now say what it costs and what would make it sayable. And `Exec` is a builtin, so an `Exec` line in the startup config types that command into a shell before the first frame -- the tutor said nothing in that file is ever sent to one. Nine adversarial reviews over two rounds, each with the hxdiff harness to execute what it doubted. The second round exists because the first round's fixes needed checking too, and it caught three regressions of my own -- one of them a probe count I had "corrected" away from the truth. Verified: unit-test, snap 87/87, hxdiff 481/0, hxparity 561/0, mupdf-check. docs/design.pdf regenerated. The tutor's first seventeen lines are byte- identical, which is what tutor.golden pins.
* clipboard, n/N and the tty prompt: three things that were half-wiredGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three changes that all turned out to be the same shape -- a feature that worked in one direction, or for one pane kind, and quietly did not in the others. CLIPBOARD. Every register write emitted set_clipboard, so deleting one character threw away whatever the desktop was holding; multi-cursor yank took the join's early return and emitted nothing at all, so the same key reached the clipboard on one cursor and not on two. Nothing could READ the clipboard: the SDL shell had no SDL_GetClipboardText anywhere in it, and the tty shell never asked for OSC 52, so `p` from another application was dead in both. Now it is helix's split. y/d/c/p/P/R and the acme chords are the DEFAULT REGISTER and nothing else; the system clipboard is five words on helix's own letters -- SPC y, SPC Y, SPC p, SPC P, SPC R -- spelled as builtins so they land in Help and are executable like every other verb. The one exception is the tag `y` chord, which still mirrors out because a tag is always insert, so SPC cannot be pressed there, and copying the path out is the whole point of the chord. Reading is a new read_clipboard effect answered by an ordinary Event.paste, so the round trip is honest about being one: SDL and NSPasteboard answer inside the same drain, the browser answers a promise, and a terminal answers over OSC 52 or -- far more often -- refuses. A refused read is a paste that does not happen, and the request dies at the next keystroke rather than landing minutes late in whatever pane is focused by then. The tty shell also enables BRACKETED PASTE now and coalesces paste_start..paste_end into one event. Before this a paste arrived as a flood of individual key presses: plausible in insert mode, and in normal mode every pasted character ran as a command. n/N. They stepped the armed results buffer and immediately Looked each row, so you could not walk past a hit without opening it. They are a MOTION now: select the next look-able text, open nothing, and let Enter decide. What they step is the largest whitespace-delimited run look.resolve can act on (look.lookableSpan, wrapper punctuation peeled), over a RING of panes -- every pane that has performed a look, most recent first, then the output buffers that have not, newest first, and only if both are empty the pane in front of you. N is the exact inverse of n, computed rather than remembered: both directions ask the same question about the same spans and compare against the column the walk parks on, so x presses one way and x back land exactly where you started, pane boundaries and the ring's seam included. A ring rather than a list with two ends because a shell's cursor sits at the prompt, below everything it has printed, so a walk that could not come round would have nowhere to go on the very first press -- which is the case n/N were written for. One motion everywhere, no pane-kind or buffer-kind special case. The only thing a buffer may change is the GRAIN of what a step selects, and it does it with one flag rather than a branch: output_pane.Traits.commands (renamed from `executes`, which named one reader's behaviour rather than the fact) makes a row select WHOLE, because a ThemeSel line is a word to run and has no path inside it to pick out. `]d`/`[d` are not n/N -- they are helix's diagnostic motions, their job is to ARRIVE, and they still reach searchStep. THE TTY PROMPT. Leaving raw tty blanked the prompt row, and the command you had typed at that prompt shares the row, so it went too -- a shell out of tty read as output only. OSC 133 marks the row CELL by cell, so the two are separable: config.tty_blank = .prompt cuts the prompt's own columns and leaves the command, left-hugged at column 0 in line with the output under it rather than in a bay of blanks. .prompt_and_input is the old behaviour, kept. Because the row is now something you can put a cursor in, enterTty adds the hidden prompt width back before asking ghostty to walk the shell's own cursor to it -- the modal column on a cut row is short by exactly that much. Verified: unit-test 186/186 (nine new), snap 87/87 (new ttyprompt.snap), hxdiff 481 and hxparity 561 with 0 mismatches, tty and gui both build. And against the real binaries rather than the harness: in a pty, SPC y emits OSC 52 carrying exactly the selection while plain y emits nothing, SPC p issues the read and pastes the reply, and a bracketed paste of "dd..." inserts text instead of deleting two lines. In a real SDL window, SPC y then SPC p round trips through the system clipboard while the default register holds different text. Setting tty_blank back to .prompt_and_input reproduces all 86 old goldens byte for byte.
* macos: pixel attachments, live theming, and a signed appGabriel Schneider2026-08-11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The AppKit shell now draws what the core renders, follows the theme without a relaunch, and builds into something you can hand to someone. - Pixel attachments. Surface.images was dropped on the floor here, so a PDF pane showed nothing at all: native_images is now set, pardes_image_s carries the geometry the core already clipped, and PardesView keeps one CGImage per (serial, page, revision) so scrolling costs a draw and not a decode. Image panes get real pixels instead of the petscii fallback. - Themes take hold live. pardes_tick never advanced the chrome animation, so every tagline kept the previous theme's colours until the next launch and the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires the hand-agreed #121212 and drives the window background and the titlebar appearance; a theme with no background of its own now gets a transparent window over an NSVisualEffectView. - The cell snaps to whole DEVICE pixels rather than whole points. Monaco advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than the face was drawn for. - The dial is one notch per 10 degrees instead of 20, and a release keeps turning in proportion to how hard it was thrown -- ramping up from zero at the floor, so a slow twist coasts not a little but not at all. - A file dropped on the grid is a click plus Look, so it opens beside the pane it was dropped on. No drop concept was added to the core. - The titlebar follows the focused pane: proxy icon, filename, and the dirty dot. File.saved_revision is the watermark that last one needed. - Config (SPC f c) prints the resolved startup config path. - build.zig assembles, signs and packages the bundle itself; build-app.sh is gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and the icon is Glenda.
* macos: the AppKit shell, its icon, and the offscreen e2e harnessGabriel Schneider2026-08-11
|
* fixed lsp renameGabriel Schneider2026-08-10
|
* fix tab rendering - waybe wrecklessGabriel Schneider2026-08-10
|
* Better text renderingGabriel Schneider2026-08-10
|
* lsp rows: paths relative to the asking file, and the completion textGabriel Schneider2026-08-10
|
* Tab after a dot in insert mode lists what could go thereGabriel Schneider2026-08-10
|
* Esc alternates between the last two panes; Toggleterm is goneGabriel Schneider2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc ran Toggleterm, which hopped between the newest DOC and the newest TERMINAL. That distinction never earned its keep. It made Esc unpredictable — which of three panes you landed on depended on their kinds, not on where you had been — and it could not alternate between two files at all, which is the case you hit most. Editing two files, Esc did nothing. The replacement already existed. Last (SPC j j) is "the pane you were in before this one, whichever it was": it walks the jump stack for the newest entry naming a different pane and restores its line and column. So Esc, and Shift-Esc in tty, now run Last, and Toggleterm is deleted rather than renamed — a third implementation of "go to the other pane" was the thing to avoid. SPC w t goes with it; the w group is the four directional moves, and the jump group already had SPC j j. Held down, Esc alternates. Two files, a file and its shell, a file and a +Search — all the same, because Last has no notion of kind to get wrong. This depends on the swap in the same series: Last reads the stack backwards, and until hopping stopped appending, the pane you came from could fall off it. windownav.snap needed only its keys and prose changed — its golden did not move at all, which is the useful evidence here: for the one scenario the old builtin handled well, Last produces an identical focus sequence. Coverage for what it did not handle is new: a unit test opens a second FILE by looking its name and asserts Esc alternates between two panes of the SAME kind, which is the case that used to be a no-op. Docs follow: tutor.txt, docs/helix-keys.md, docs/design.typ, and the builtin index goldens, which are now one row shorter. 75/75 snapshots, both unit suites, and the macOS ABI build all pass.
* a native macOS backend: libpardes plus an AppKit shellGabriel Schneider2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass.
* show New in every pane tag and enforce builtin-first Exec fallbackGabriel Schneider2026-08-10
|
* prioritize useful topbar commands and put Kill lastGabriel Schneider2026-08-10
|
* add New builtin for an empty temporary file in the calling columnGabriel Schneider2026-08-10
|
* load builtin commands from the user config before renderingGabriel Schneider2026-08-10
|
* normal Esc toggles document/terminal focusGabriel Schneider2026-08-10
|
* add vanilla DOM web backendGabriel Schneider2026-08-10
|
* a Look path can name a range, and search selects what it foundGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | file:LINE:COL-ENDLINE:ENDCOL, with the two short forms people actually type reading naturally: file:412:9-21 on one line, file:412-418 whole ones. Ends are inclusive. A path feature, not a search feature — a ranged path typed in a tag or middle-clicked out of a shell's output selects just the same; search is only its first consumer. The dash is the fussy part. `-` was already a file char, so a ranged word survives click expansion whole, but a range needs a number on BOTH sides or my-file:10, build-2 and 2026-07-30 would stop being paths. Table-driven test in look.zig for exactly that. Selecting goes through the cellRange/setPaneRange pair the multi-cursor work left, and hxOff clamps both ends, so a stale range selects what still exists rather than crashing or reaching past EOF — pinned with an 8:6-400:9 range in a nine-line file. Producers: / search, Grep, and five LSP sites through a new spanRow — goto, references, rename tokens and both symbol lists were throwing away real protocol ranges at path:line:col. Left alone deliberately: Find rows are bare paths with nothing to span, a jump is a spot not a span, and the diagnostic and format paths only ever have a point, where half a range would be worse than none. One knock-on worth knowing: n now leaves an EXPLICIT selection, so a topbar execute chords it. grep.snap's no-match step was silently becoming `Grep TARGET`; it runs from the leader path now, which never chords, and the dedicated chord steps stayed where they were.
* every helix and zed theme on the machine, not a curated nineGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 228 in the ring: 3 pardes ships plus all 214 helix runtime themes and all 11 zed variants across its three families. No exclusions. inherits is what this cost. 76 themes need it, so the reader is two passes now, and the merge is WHOLE-KEY rather than field-wise because helix merges the theme body at depth 1 — the three *_transparent themes clear their parent's background with an empty table, and a field-wise merge would leave it painted. I had that wrong until I read helix-view/src/theme.rs. At 214 files the generator meets themes that are SPARSE rather than broken, and the old fatal-on-anything-missing would have rejected them. Every fallback is helix's own rule: no background at all means wear the terminal's, so bg and fg go null (13 themes, correctly); the twelve RGB fields that cannot be null fall back to ghostty's default palette entries 7 and 0, which is exactly what a pardes terminal already paints an unstyled ANSI index as. fatal is kept for what genuinely cannot be read. Three real bugs surfaced only at scale: #ccc shorthand doubles the nibble (helix's rule, 58 colours), a quoted inline-table key silently dropped a selection colour, and four themes name a cursor background EQUAL to the page because they are about to reverse that cell — taking it painted an invisible move box. 0 degenerate themes out of 225 now: no fg==bg, no syntax colour on its own background, no invisible box. Collisions get an unconditional rule rather than a clever one: a zed variant is always <name>_zed, because both projects ship gruvbox, ayu and one, and a conditional tag would move a name when the other source changes. A comptime assert holds it. Cost: no-op build 0.257 -> 0.276s, a pardes.zig change 46.8 -> 47.5s, binary +0.98%. The generator does 217 sources in 54ms. NextColor stays and is no longer a way to REACH a theme — but the browse got better, not worse: the generated half sorts by name, so its neighbours are that theme's own family. theme.golden's fourth click moved from ayu_dark to acid, which sorting 225 names does; themesel gained a tail capture proving row 228 renders.
* multiple cursors, regex selection, and Ctrl-c commentsGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and sels[] holds helix's OTHER ranges. That split is why nothing moved at one cursor: with nsel == 0 not one line of the existing motion, operator, render or mouse code takes a different branch, which is what protects 800 differential cases and 67 goldens. paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's Selection::new (min width 1, sorted, overlaps merged, primary follows its range through a merge). An ordinary key runs the single-selection handler once per range, visited last-first so an edit never disturbs a range still waiting, and each finished pass is remembered as a distance from the END of the text, which an earlier edit cannot move — helix's change mapping without a change map. pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from the primary and stops the replay, which also closes the use-after-free window if it frees the pane. s and S reuse the / prompt wholesale rather than growing a second one: the pattern is typed into the tag tail, and every keystroke re-runs the match from the selection the prompt opened on, so the preview is live and Esc is just the empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size struct with no allocator — with 64 ops and 8 char classes per pattern, no case-insensitive flag (helix's smart case is done by folding a scratch copy), no captures, no multi-line anchors. The last two are the two waivers. Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides comment-vs-uncomment ONCE for the whole selection; replaying it would take that decision n times. Comment tokens are a table in config.zig keyed on the same extension syntax.zig picks grammars by. Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left the cursor one cell before where the append began. helix's restore_cursor can never walk past the origin; ours backed up unconditionally. hxdiff was green before AND after — the old one-selection contract could not see it. hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the harness contract now reports every range and its primary, omitted when there is one, so 359 of the 360 old goldens are byte-identical. The one that moved is o-count: helix's 2o really does leave two cursors and could not say so before.
* themes: one file each, generated from helix and zed, picked by stepping a listGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | Each theme is a .zig file of pure data and nothing enumerates them by hand — fold() walks the container's declarations, so a theme is a file and that is the whole registration. Field by field rather than a wholesale coercion, which makes a missing field a compile error that names it. tools/gen_themes.zig reads helix .toml and zed .json out of vendor/themes and emits one .zig each; build.zig reads that directory, so adding a theme is dropping a file in. Output goes to the build cache rather than the tree, so zig owns the freshness check and a deleted source cannot leave a stale theme behind. Vendored, not read from the genizah: the build stays offline. A source it cannot map is a hard error naming the file and the key, never a silently black-on-black theme. ThemeSel is the clever half. Traits gained an `executes` column, so n/N over that buffer hands the whole line to Exec instead of the leading word to Look — both arms the ordinary builtin, so a stepped row does exactly what the matching mouse button on it would. Stepping the list previews each theme live. The trait is a pane property, not an output-pane branch, so any pane whose lines are commands can opt in. Goldens: leader gains SPC t t; theme's fourth NextColor no longer wraps to helix because the ring is fifteen long. New: themesel.
* focus history is a stack of locations, and the Jumplist is that same stackGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's directory order, Back/Forward, Last and Jumplist all walk the one list. A pane id is reused, so a location that only remembered an id would retarget after a respawn: panes now carry a monotonic serial and an entry whose slot holds a different serial is dead. trackJump compacts those out and fixes the pointer in the same pass. The push rule lives in ONE place and says: a location is worth remembering when you cannot see it any more — a different pane, or more than a bodyful of rows away in the same one. So hjkl never grows the list and 100G, a search hit and a goto-definition do. Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them. Ctrl-i IS Tab on a legacy host, where the binding simply never fires and Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere. Two goldens moved, both the SPC ? Help listing gaining four rows.
* Look and Exec are builtins like any otherGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | Their only special feature is now the keys they are assigned. `Look main.zig` typed in a tag is the same look a right click is; config.look_cmd/exec_cmd point the two buttons and Enter/Tab at them, so retargeting Enter to Grep is one line. actOnSelection — a hand-written cascade with the builtin dispatch nested inside it — is gone, and the `button` parameter it threaded through five KEYBOARD call sites went with it. New syntax, spelled once in config.zig: @`ls -la` names a command to run rather than a file to open. Word expansion takes the quoted run whole, the way acme does for its own </|/> words, so a click inside one does not hand Look the fragment `ls`. And it nests: @`Look .` unwraps, re-enters the dispatcher, and looks at the directory. Guarded at depth 8, which is unreachable today (every re-entry strips a word or a delimiter pair, so the string strictly shrinks) and exists for a future syntax that does not shrink. leader_path is now optional: a builtin may have no SPC path when its shortcut is a key and a mouse button. New golden cmdword; the other 59 unmoved.
* structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into ↵Gabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | their own files The core now lies FLAT at src/ and every subdirectory is one backend, so a file being in no directory at all is what says it is core. Pane-kind bodies leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving it the layout, the event/effect machine and the generic render loop. Builtins are one struct each in builtins.zig, and the enum is folded out of the file's own declaration list at comptime — a zig file IS a struct, so the list of builtins and the builtins themselves are the same text. Adding one is writing a struct. Key paths deliberately stay one table for the config pass. Pure refactor: no golden moved.