summaryrefslogtreecommitdiff
path: root/src
Commit message (Collapse)AuthorAge
...
* Distinguish filename hues while preserving tag brightnessGabriel Schneider2026-09-15
|
* Expose LocationsConfig in location result pane tagsGabriel Schneider2026-09-15
|
* Follow embedded PDF links through LookGabriel Schneider2026-09-15
|
* Remember tag cursors and reserve uppercase pane navigationGabriel Schneider2026-09-15
|
* Tint pane tag filenames with an optional theme colorGabriel Schneider2026-09-15
|
* Align location results and configure source contextGabriel Schneider2026-09-15
|
* Add optional tree-sitter declaration contextGabriel Schneider2026-09-15
|
* Mirror raw terminal mouse selections for text transferGabriel Schneider2026-09-15
|
* Align file clicks and insertion cursorsGabriel Schneider2026-09-15
|
* Route mouse thumb buttons to jump historyGabriel Schneider2026-09-15
|
* Preserve selections when navigating jump historyGabriel Schneider2026-09-15
|
* Add Linux Tty9p mounted terminals and forward raw TTY keysGabriel Schneider2026-09-15
|
* 9p: use cloud9 protocol sessions and transportsGabriel Schneider2026-09-15
|
* trunk: resume before the Reload experimentGabriel Schneider2026-09-15
| | | | Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
* Resolve relative restores in the dump directory and propagate LSP probe errorsGabriel Schneider2026-09-15
|
* macos: merge local trackpad gestures with current upstreamGabriel Schneider2026-09-07
|\
| * macos: use two-finger click for Exec and deep press for LookGabriel Schneider2026-09-07
| |
* | Refactor panes and filesystem; replace FUSE with 9PGabriel Schneider2026-09-07
| | | | | | | | | | | | Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
* | syntax: a results buffer is coloured as the code it quotesGabriel Schneider2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | +Grep, +Search and every language answer render rows like src/look.zig:718:12-16 fn grepText(path: []const u8, text: []const u8... — a location, a space, and a piece of some file. The location names the file, the file names the grammar, and the rest of the row is a fragment of that language, so a grep over Zig reads as Zig and one Markdown row in the same buffer does not pretend otherwise. The location itself is left uncoloured: it is not code, and painting it as code is how a path starts looking like a keyword. `look.parsePathLine` decides what counts as a location — the same primitive n/N already walks these buffers with, so the two agree by construction about which rows are locations. NOT `lookableLineSpan`, which is n/N's whole heuristic: it calls `resolve`, and a `realpath` per row per scroll is not something a render path can afford. A bare filename is refused too; only `path:line` counts, or a prose line whose first word ended in `.md` would colour the rest of a sentence. THE BUFFER IS COLOURED WHOLE, ONCE, WHEN IT IS FILLED. An adversarial pass measured the obvious per-window implementation and it was untenable: the rows are independent, so a window pass buys no fidelity, only amortisation, and pays a burst on every scroll that outran the covered range. Grammars compile their highlights query on first use — zig 26.9ms, cpp 18.9ms, rust 14.3ms — so a polyglot grep showing six languages stalled a frame by 66ms, moving a cost the syntax module had deliberately put on "opening a file" onto a scroll. It also raised tree-sitter's allocation rate 3.5x (8,785 per refresh against 2,454) into a 16 MiB bump arena that only reclaims LIFO, so ~16 scroll re-highlights exhausted it — and that arena is shared with real file panes, so a results pane could evict editing. A grep is capped at 512 rows; colouring it once makes the covered-range check true forever after and scrolling free. The rest of that pass, in the same spirit: injections off for a single row (both build a SECOND parser, per fenced block and per inline node, which is absurd for one truncated row that almost never contains a fence), one query cursor for the buffer instead of one per row, a one-entry extension memo so non-matching rows stop paying a 29-spec scan, and NO highlights at all when nothing painted — an all-zero run is not the same as none, and it defeated `recolorSyntax`'s fast path, making every +Help and +Config walk its graphemes every frame to paint nothing. One correctness bug from the same pass: a failed `setLanguage` has already nulled the parser's language, so leaving `held` on the previous grammar made every later row of it skip the call and silently lose colour. Documents keep `.source`: a New scratch and a real file are output-shaped but have one language and an edit per keystroke, and `saves` is the line between the two. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | messages: a fixed log of what the rows said, and a word to read it backGabriel Schneider2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A message row is cleared by the next keystroke, so anything reported while you were looking at another pane was gone before you could read it — a save that failed, a watcher's reload, a builtin's complaint. `setMessage` now records into a fixed ring first: no allocation and no failure path, because it sits underneath `reportError`, which is reached from sites that are reporting an allocation failure. `Messages` (`SPC h m`) reads it back oldest-first. Three things an adversarial pass found, each of which defeated the feature: PROGRESS IS NOT A MESSAGE. A language server emits `Indexing 47%` several times a second, and every tick is a distinct string BY CONSTRUCTION, so no de-duplication can collapse it: at the client's one-per-150ms throttle it takes about nineteen seconds to push every real message out of the ring. A log that one indexing run empties is not a log. That path is `setStatus` now — the row, and nothing else. THE CLOCK MADE EVERY HOST MESSAGE UNIQUE. `message.stamp` prefixes `HH:MM:SS`, so `saved /x.zig` at 14:32:07 and at :09 compared unequal and the ring filled with rows that look identical and each say (x1) — exactly the case the de-duplication exists for. It compares `message.body` now, the row without its clock, and the newest wording wins so the row carries the last time it happened rather than the first. It also keys on the PANE (one pane's failure must not be recorded as another's) and compares the truncated form, so two identical messages over 256 bytes stop being two rows. AND THE CAPACITY BELONGS IN limits.zig. 128 entries is 32.75 KiB that is allocated whether or not anybody reads it — 8.5% of the ESP32-P4's whole 384 KiB heap, about the size of its effect ring. The board takes sixteen. The builtins/leader goldens move because the listing gains a row, and builtins.snap middle-clicks a SCREEN COORDINATE that Tutor moved out of; both updated selectively and verified against a fresh run. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | pipe: helix's other three shell commands, and its newline ruleGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `|` was the only one of helix's five. The other four differ in exactly two things — whether the selection is stdin, and where the output lands — so they are one action carrying a `PipeBehavior` rather than four code paths: | stdin is the selection, output REPLACES it (had this) A-| stdin is the selection, output discarded shell_pipe_to ! no stdin, output inserted BEFORE each selection shell_insert_output A-! no stdin, output appended AFTER each selection shell_append_output Each arms the same visible tag-tail prompt with its own marker (`|`, `|-`, `!`, `!+`) so the prompt says which one you are in — they take the same command line and do very different things to the buffer. Two helix rules came with them. A behaviour that sends no stdin runs the command ONCE and every cursor gets that one answer (helix's `shell_output` cache): ten cursors and `date` give ten identical stamps rather than ten forks racing to produce one. And a command that put a trailing newline on a selection which did not have one has it taken back off — that is what keeps a one-line `| tr a-z A-Z` from becoming two lines. The existing multi-range test moved with that rule and now pins it deliberately. In all three writing behaviours the OUTPUT is what ends up selected, keeping the original range's direction, so an operator can follow straight on from what the command just produced. `$` (`shell_keep_pipe` — drop the selections whose command exited nonzero) is still missing: it needs a per-selection verdict and the runner's answer is atomic. Noted in docs/helix-keys.md beside the `$` divergence already there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | pipe: a filter that fails says so, and `| head -1` stops failingGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things made the selection pipe feel like it had never worked. It runs — test/snapshots/pipe.snap drives the real binary through a pty and filters `alpha beta` to `ALPHA BETA` — but it had no way to tell you when it did not, and one of its failure conditions was not a failure at all. IT NOW SAYS WHY. `runOne` read the command's stderr into memory and freed it two lines later, unread; every caller answered a failed filter with a bare `return`; `pipeResponse` had eight more silent exits under that. So `| trr` (a typo), `| grep nomatch` (exit 1), `| jq .` on bad JSON — all did nothing, said nothing, and left the text alone with no way to find out why. The runner carries a `Failure` home instead: which selection, what became of the command, and its own stderr. The core turns that into an `+Errors` buffer — acme's name for output that came from the program rather than from a word anybody clicked: | trr exit status 127 sh: line 1: trr: command not found An output buffer rather than the message row because the useful half of a shell failure is the text the shell wrote, and a 256-byte row would keep the label and throw away the reason. Focus stays with the file: `openRead` moves `p.active` to what it opens, which is right for a Grep you asked to read and wrong for a report you did not — you want to fix the command and press `|` again. A host with no `pull_pipe` at all (the detached daemon, the browser, the board) now says that too, instead of answering failure into the void. `| head -1` NOW WORKS. `writer_context.ok` was part of the success condition, so a command that stopped reading its stdin failed the filter even though it had done exactly its job: `head` takes the line it wants and closes the pipe, the write gets EPIPE, and a selection bigger than the 64 KiB pipe buffer was enough to trigger it. helix joins its input task and ignores the result for this reason; the exit status is the whole verdict. Also reported rather than swallowed: the ten-second timeout, the output ceilings, and a file edited while the filter ran — one keystroke during a slow command used to discard the result in a way indistinguishable from the filter doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | chords: Look and Exec act once per selection, and say when they cannotGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Both acme chords read the PRIMARY range and dropped every other cursor on the floor — the one thing a multi-cursor editor must not do with a command the user aimed at all of them. They are also structurally outside the machinery that would have handled it: the Enter/Tab chord is intercepted before `handleNormal` so it never becomes an Action with a scope, and `runBuiltin` bails on `multiOnce` anyway, because a builtin is per-keystroke rather than per-cursor. So `chordEachSel` takes the `submitPipe` shape instead: `paneRanges` once, forward in document order, every range's bytes COPIED before the first builtin runs. The copy is not caution — a `Look` opens panes and an `Exec` can run a builtin that edits or closes the pane those offsets point into, and a selection whose text is `Del` is a legal Exec. The loop re-checks the slot and its serial between iterations, the same guard `replaySels` makes for the same reason. With one cursor it returns false on the first line and the old path runs untouched. FOCUS FOLLOWS THE PRIMARY. `lookAt` sets `p.active` for every target it opens, so `Look` over four selections used to leave you at whichever one happened to sort last — an accident rather than an answer. ...AND A LOOK WITH NOWHERE TO PUT ITS ANSWER SAYS SO. All four slot checks in `lookAt` were a bare `orelse return`: with one selection that merely felt like a dead key, and with several it means "I opened nine of your fourteen and told you nothing". They report `NoPaneSlots` now, through the channel output_pane.zig already raises it on and a test already pins. The three openers report their own failure too, so a file that will not open says whether it was permission, a pipe, or size — which `look.readFile` only started distinguishing this week. Known and left: N selections that all resolve to nothing run N searches over one +Search buffer. Wasteful, converges, and worth its own change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | grep: one file it cannot read is not the end of the searchGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `look.grep` aborted the whole walk and returned `OpenFailed` the moment any file refused to open. One root-owned 0600 file in the tree — or one deleted between the walk and the read, which is routine in a build tree — turned a search of ten thousand files into zero results and a word on the message row that explains nothing. A grep is a question about the files you can read, and the ones you cannot are not an answer to it: they are skipped now, along with a read that fails partway. Opened NONBLOCK for the reason readFile has it, so a FIFO in the tree cannot stop the search until somebody writes to it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | errors: a mistyped flag is a sentence, and a pipe is not a documentGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every argv refusal in main.zig was `return error.BadArgs` out of `main`, which std prints as `error: BadArgs` with a return trace under it — the same shape a real crash has, for the most ordinary thing a person can do. It also said `BadArgs` and nothing about which argument, at sites that knew exactly: pardes: no such option: --hepl pardes: -n takes 1 or 3, not 'abc' pardes: one file or directory at a time, and 'b' is the second pardes: --detach and --attach are opposites: one runs the session, the other joins one Try 'pardes --help'. stderr rather than the `+Errors` pane one function down, because argv is read before a core exists and the person who mistyped a flag is looking at the prompt they typed it into. `--attach`'s refusal already answered this way; now all eleven do. `getcwd` failing is no longer reported as an argument problem, and a `.url` or `@pN` positional says why a LAUNCH cannot act on it rather than being swept into the same word as a typo. AND `Look` ON A FIFO NO LONGER FREEZES THE EDITOR. `readFile` opened with a plain blocking `open`, so a named pipe with no writer waited forever — inside the keystroke that asked, with no frame, no message row and, in the tty shell, no Ctrl-C either, because the terminal is in raw mode. It is `O_NONBLOCK` now, the read loops answer `EAGAIN` rather than waiting, and `lseek` answering ESPIPE — a pipe, a socket, a terminal — is refused as `NotAFile`, which the boot pane spells "that is a pipe or a device, not a document". Without that last part an unwritten FIFO read as EOF and opened a silent empty pane, which says less than the hang did. The zero-size files worth streaming (procfs and its kin) seek fine and are untouched. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | errors: a save that could not happen, and two panics on an ordinary clickGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A review of what this program does when the environment says no. The finding that reframes it: there were almost NO panics on ordinary paths — the rule already held — but there was a great deal of silence, and one case worse than any panic. SILENT DATA LOSS ON SAVE. `saveFile` marked the pane saved the moment it QUEUED the effect, before any host had tried; `host_io.writeFd` returned void, so a short or failed write was indistinguishable from a complete one; and `writeFileBytes` returned true regardless. A save to a read-only file, or into a directory removed under the pane, therefore cleared the tag's ` *` and posted nothing — and `Del` makes no dirty check, so the next click threw the edits away with the screen saying they were safe. On a full disk it was worse: the file is already `O_TRUNC`'d when `write` fails, so the message row said `saved` over a file that had just been emptied. Now: `writeFd` reports, `writeFileBytes` returns WHY (`PermissionDenied`, `NoSpaceLeft`, `ReadOnlyFilesystem`, …) including a failed `close`, which is where write-back filesystems report at all; the core marks the pane saved around `perform` rather than at emit, which is also where the bytes are read; and a host that could not write calls `Pardes.saveFailed`, which puts the reason on the message row and takes the clean mark back. That is a CALL and not a return value because host.zig enforces, at comptime, that a `push_` method reaching every host in a fan-out cannot have one answer — the first attempt at this changed the signature and the compiler was right to refuse it. TWO PANICS ON AN ORDINARY KEYSTROKE, in look.zig's number scans. `v = v * 10 + d` over caller-supplied digits, reached from `parsePathLine` and the `@pN` scan — which every Look, every right-click and every n/N motion runs on whatever word is under the pointer. A hash in a log, a CSV column, any output shaped `foo:99999999999999999999`, and the editor died with "integer overflow". Both saturate now, the same way acmefs.zig's address parser already did; a saturated line is refused by `file_pane.open`'s `line <= total` and a saturated pane id by `focusPaneLine`'s `id < MAX_PANES`, so nothing addressable changes. A BOOT FILE THAT WILL NOT OPEN joins the missing-name case in the `+Errors` pane instead of taking the launch down: `pardes /root` resolves as a `.file`, could not be read, and left `error: PermissionDenied` and a return trace. `look.readFile` now says which errno it was, so the pane can say "permission denied" rather than a word from the source code. The tag-marker test drained no effects and passed anyway, which is exactly the defect; it drains now. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | boot: the errors pane keeps the launch directory, and a typo inside pardes ↵Gabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | stays one line Two defects in the +Errors boot, both found by adversarial re-review. The pane was opened with `dir = ""` — copied from the board's boot buffer, which can afford it because that platform has no filesystem — so its path came out `/+Errors` and `paneDir` answered `/`. An output pane's directory is where a `Grep` from it walks, where its `Newtty` spawns a shell and what its `Save` prefills, so the boot screen rooted all three at the filesystem root, and the one word the pane prints resolved against `/` and could never be clicked. The launch directory rides in `Options.missing` beside the word now, and the test asserts the pane's path rather than only its contents. A typo INSIDE pardes stacked a second full-screen UI. The hand-off block above resolves the word and sends it to the outer instance; `.none` sent nothing and fell through, which was harmless while the classification below refused it and became the one input that stacks the UI that block exists to prevent — with no shell pane in it, so the only way out is `Del`. Its own comment said as much and was falsified by the +Errors boot. `.none` is refused in that shell now, in one line and without a stack trace, and the outer session is not told: `Look` on a word naming nothing is not something to do to somebody else's session. Also recorded, not fixed: the commonest permission case never reaches the `.dir` arm this arm's comment defends. `look.isDir` probes with O_DIRECTORY| O_RDONLY, so a directory you cannot read resolves as `.file` and dies in `file_pane.open` with `error.OpenFailed` out of `main` — still a trace at a human, and a different fault than the one fixed here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | crash: a panic record must not be able to hang the process it is recordingGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adversarial re-review, confirmed against std's source and then measured. `captureCurrentStackTrace` is not the safe half of `writeCurrentStackTrace`. `StackIterator.init` picks the `.di` strategy whenever `SelfInfo` can unwind, `stratOk` accepts `.di` regardless of `allow_unsafe_unwind`, and `.di` takes `SelfInfo`'s rwlock EXCLUSIVELY on its first call — the only kind of call a panic record makes — across `dl_iterate_phdr`, a DWARF CFI machine and an allocation. A panic in there (a smashed stack is a leading reason to be in a panic handler at all) leaves the lock held, because the unlock is a `defer` in a frame that never returns, and `defaultPanic` then waits on it for the life of the process. A crash becomes a hang, which is worse than what this file was added to improve on. The frames stay on stderr, where defaultPanic prints them under the staging that makes them safe; the record keeps what can be gathered without asking the process any questions. ONE record per process, never released. With the guard released on the way out, one panic wrote two records: the real message, then "reached unreachable code" under it. That second panic is this handler's own `vaxis.recover()` running a second time — it closes the vaxis tty and never clears the global saying there is one, so the double close is `recoverableOsBugDetected` and an `unreachable` in a Debug build. Guarded now in both the panic and the segfault handler; that half is a fix older than the crash file. `clock_gettime`'s return is checked, unlike dump.zig's, because a failure here leaves `ts` undefined and an undefined large-positive `sec` walks `calculateYearDay`'s u16 year past 65535 and overflow-panics inside the panic handler. Debug fills it with 0xaa and lands in 1970, which is why it reads as harmless. Verified end to end with a temporary probe: one panic, one record. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | boot: argv naming nothing opens an errors pane, not a stack traceGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `pardes nosuchfile` returned error.BadArgs out of nativeMain, which std prints as `error: BadArgs` with a return trace under it — indistinguishable from a crash, for a typo, and it left the human with no editor at all. A launch that names something look.resolve cannot make a target of now boots one +Errors pane filling the window, saying `file or directory not found` and the argument AS TYPED: acme's own vocabulary for output that came from the program rather than from a word somebody clicked, and the word rather than a resolved path because `pardes ~/notes/tdoo.md` wants to see its own typo back. A chdir that fails on a directory that really is one stays BadArgs. That is a permission problem rather than a typo, and the two want different answers. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | detached: a big screen can attach, and the frame it asks for fits the wireGabriel Schneider2026-09-03
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two defects, and either fix alone makes the other one worse. No frontend ever clamped its window to the protocol's grid ceiling — the hello carried it raw — so a 4K display at a small font, already past max_rows 128, had its geometry refused by the session's decoder as BadValue. That path answers with close(.protocol) and no refuse behind it, so the frontend was told only that the session "hung up on the connect": at a session with all 32 slots free. client.zig now asks for the largest grid the wire carries, which is what its own GEOMETRY note already promises a frontend gets — the session is drawn at its own size in the corner of a bigger window, exactly as when another frontend is the smaller one. A ZERO geometry is dropped rather than clamped, because the session grid is the smallest common one and a frontend reporting 1 would collapse everybody else; TIOCGWINSZ answers 0x0 during a teardown and the tty shell forwarded it, which was the same mute hangup by another route. That clamp alone would have replaced one bug with a worse one. max_cols * max_rows is 65536 and a run's length prefix is a u16, so the single grid legal at both bounds is the one grid whose full frame — and an attach always produces a full frame — cannot be described by one run. encodeFrame's @intCast panicked in a safe build and was illegal behaviour in a fast one. The encoder splits the run instead, bounding the CURSOR rather than the run because the gap lookahead runs ahead of it, and frameBound had already paid for the extra header. wire.version 1 -> 2 for the same reason: the geometry a v2 frontend now asks for is one a v1 daemon panics encoding, and `zig build` replacing the binary under a running session is exactly what that field exists for. A v1 daemon answers Refusal.version instead of dying with every pane shell it owns. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* | crash: a panic writes itself down beside the init file, where stderr cannot ↵Gabriel Schneider2026-09-03
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | lose it Every crash this program has ever had went to stderr and nowhere else, and stderr is the one place it cannot keep anything. In the tty shell stderr IS the screen, so the trace lands on the grid the terminal is being reset out of; the SDL and AppKit shells have no terminal at all; a --detach session's goes wherever its launcher left it. src/crash.zig appends a record to <config dir>/crashes first: one line naming the build (version, commit, UTC, os-arch, pid) and under it the panic message and the frames behind it. RETURN ADDRESSES and not the symbolised trace, which is measured rather than chosen. `std.debug.writeCurrentStackTrace` called from a panic handler BEFORE defaultPanic wedges the process at 0% CPU: symbolising reads DWARF, that read can itself panic, and the staging which turns a nested panic into "aborting due to recursive panic" is defaultPanic's own and private. Reproduced in a standalone build with this program's std_options_debug_io and inside a test binary. `captureCurrentStackTrace` only walks frames, so the addresses go in the file and `addr2line -e` finishes the job; stderr still gets the symbolised trace from defaultPanic, unchanged. The AppKit shell gets a panic handler of its own here too: the macOS build roots at macos.zig, so main.zig's had never run there — in the shell with the least useful stderr of the four. The config directory is COPIED rather than borrowed, because that host's lives in an arena its own errdefer frees. One record at a time, so two panicking threads cannot interleave into one buffer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
* 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.
* macos fixGabriel Schneider2026-09-01
|
* detached: a frontend that has not finished reading is not a screen that differsGabriel Schneider2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `detached session: input from a frontend reaches the core and comes back as a diff` failed about three runs in five, always the same way: cell 122, the core holding `g` and the frontend a space. Cell 122 on a 60-wide grid is row 2 column 2, and row 2 is the pane shell's first line — the `g` is the front of `goblin@pardes ...`, the prompt bash printed into the pty at whatever moment it felt like. Nothing in the transport was wrong. The frame carrying that prompt was sent, arrived, and was sitting unread in the client's socket: instrumenting the mismatch printed `drained 1 more queued messages` and then `same after drain: true`. `pumpUntil` returns the instant it decodes the message it was asked for and abandons the rest of the burst, while every `pump` presents one frame. One `c.wait(5)` that times out — and under a loaded test binary one does — buys a second pump before the first frame is read, and from there the client is one frame behind for the rest of the test. Harmless while the only thing in that frame is nothing; a cell that differs the moment a forked shell writes its prompt. So the assertion was comparing the core's NOW against the frontend's THEN, and the fix is the one this file already made for two frontends: converge. `pumpUntilShowsCore` is `pumpUntilSameScreen` with one client instead of two — pump, wait, drain EVERYTHING, compare — checked before the first pump so a test already in sync spends nothing, and handing its last comparison to `expectSameScreen` so a transport that genuinely drops a cell still fails by naming it rather than by timing out. Both single-frontend screen assertions take it; the two-frontend one already had its own. 8/8 clean gui runs against 3-failures-in-5 before, and the tty suite's 457 unchanged.
* gui: the headless grid host keeps the core's own clipboard, so paste works thereGabriel Schneider2026-08-28
| | | | | | | | | | | | | | | | | | | | | | | | | | | | `Shell.host()` hands the headless `PARDES_TEST_GRID` harness `grid_vtable`, which inherited `push_set_clipboard` and `pull_read_clipboard` from the windowed vtable. Both of those functions opened with `if (s.gui == null) return;` — and in grid mode `s.gui` is null by definition, so the pair were methods that could never answer. host.zig says what a NULL method means in as many words: "Null answers immediately from the in-process clipboard instead, so a request never goes unanswered." A method present and mute is the one case that contract does not cover: `SPC y` went nowhere and `SPC p` waited for a reply nobody was going to send, so paste was dead in the only mode of this shell a test can drive — which is also why the SDL shell's clipboard had no coverage at all. Null them, and the grid harness runs on the same in-process clipboard `pardes-isolate` does. The two SDL functions then have no reachable `gui == null` path left, so the dead guards go with them. The regression is the round trip through the real seam: build the Shell the harness builds, take the host off `Shell.host()`, assert it picked `grid_vtable`, then `SPC y` and `SPC p` and check the bytes came back. It fails on the old vtable at `clip_pending == null` — the paste that never arrived. Verified end to end as well: `PARDES_TEST_GRID` with `x`, `SPC y`, `SPC p` duplicates the line and marks the buffer dirty; the shipped binary does nothing. The windowed shell is unchanged and still reads the desktop clipboard (checked on wayland and x11, keys injected at evdev level, against a real focused window).
* tutor: half the length, and the parts people actually ask aboutGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 1297 lines to 498. The tutor had grown to the point where the things a newcomer needs first were buried in the middle of a helix reference, and two subsystems it never mentioned at all. REORDERED by what is most different from editors people arrive from, and each part now earns its place: 1 THE MOUSE acme's three buttons and the chords, trimmed 2 PANES NEW. Moving between them, in one place 3 THE TTY Ctrl-b, Shift-Esc, and what plain Esc does 4 DETACHED NEW. The core outliving the terminal showing it 5 THE KEYS the helix half, cut hard, practice blocks kept 6 THE REST PDFs, images, the language backend, scripting PANES was scattered across 3.12, part 2 and the summary; it is one section now, because "how do I get out of this pane" is the question that actually gets asked. Ctrl-w hjkl, Esc, Shift-Esc, Ctrl-o/Ctrl-i, Alt-n, Alt-c, and the three panes that have their own claim on Escape. THE TTY gains the rule the old text got wrong. It said Esc "goes to the program" in raw tty, full stop. It has not for a while: at a shell PROMPT plain Esc hops away like Shift-Esc, and only a program that has TAKEN the tty (vim, a pager) keeps it. That is `takesCommandLine` in pardes.zig, and it is the difference between the toggle feeling obvious and feeling arbitrary. DETACHED did not exist in the tutor at all. `--detach`, `--attach`, the Attach/Detach words and their chords, why the pane shells belong to the session and never stop, why N frontends share ONE screen at the smallest common grid, and that `--fs`/`--fs9` work in a daemon now. THE KEYS lost the most: thirteen subsections became six, roughly thirty practice blocks became six. What went is the enumeration a reference does better; what stayed is the handful of blocks that teach the one idea helix users do not arrive with -- motions SELECT, so `wd` is what `dw` was -- plus the count rule, which is the other thing that surprises. Part 6 documents `pty/`, `--fs9` and the `9p` word, none of which the tutor knew about. The first seven lines are byte-identical on purpose: test/snapshots/ tutor.golden pins them, and it regenerated unchanged. ALSO REGENERATED: test/snapshots/builtins.golden, which had been stale since the `9p` word was added a few changes ago -- one more builtin shifts every row of the `SPC ?` listing below it. Nothing was wrong with the code; the golden had simply not been updated with the feature. All 95 snapshot scripts pass.
* 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.
* fs_service: the transport is a ctx and three functions, not a *fuse.FsGabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | Step 2 of the 9P chain (docs/9p.typ 12.2, docs/registry.typ 9P-2). `drain` and `step` never asked a `*fuse.Fs` for anything but `retry()`, `next()` and `reply()`, so the concrete pointer was a coupling that bought nothing and forbade a second answer. `Transport` names the three; `Fs.transport()` is the first implementor and the thunks are the entire cost. No behaviour change. The order contract -- retry() to null, then next() to null -- moves into `drain`'s doc comment, where it belongs: it is the caller's rule and every implementor inherits it, rather than a fact about FUSE. `start` and `wake` keep their `*fuse.Fs`: they are about a MOUNT, which is a FUSE thing, and a 9P listener will bring its own. Measured unchanged against zig build fs-bench -Doptimize=ReleaseFast: getattr 19 ns, lookup 40, read body 4K/1M 25/25, read ctl 385, read index 633, readdir 38, read event (empty) 22, all at zero allocations.
* detached: a daemon serves acme's control filesystem, in its own poll and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | with no thread Step 1 of the 9P chain (docs/9p.typ 12.1, docs/registry.typ 9P-14). A detached session was the one configuration no script could drive. The core, the panes and the undo history outlive every frontend that attaches -- and the filesystem that would let a program read or change any of it was never mounted, because `push_fs_reply` was one of the host methods this process left null. Nothing prevented it; the call was simply not there. It costs less here than in the desktop shells. They start a thread that blocks on poll() and pokes a loop it does not otherwise share (`fs_service.wake`); this process already runs ONE poll over its listener, its frontends, its pane shells and inotify, so /dev/fuse is one more descriptor in the same syscall and there is no thread at all. `Source.fuse`'s arm does nothing on purpose: being in the set is the whole point, because the wake must end the sleep so that `pollFrame` -- which runs after `pull_wait_input` returns, where re-entering the core is legal -- reaches the drain. `main.zig` refused `--detach --fs` outright, with a comment saying that serving it would mean mounting FUSE in the detached core and that this was a feature rather than a fix. It was right, and this is the feature. `--attach` is still refused: a frontend has no core to serve. Verified against the project's own clients: examples/acmefs/pardesctl panes, new, send, body and del all drive a daemon, and the pane shells it forks now inherit PARDES_FS/PARDES_PANE like every other host's.
* 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.
* One core behind N frontends, the board's own runner moved in, and every ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | board cap on one screen ## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
* A Gpio word that flips one pin, JP1 drawn in ASCII, and these words only on ↵Gabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | the P4 ## Gpio `Gpio 33` flips one pad and answers on the message row with what it did: GPIO 33: 0->1 GPIO 33: 1->0 Bare `Gpio` draws the header instead, because the first question about a header is which pins it has. The pin number is DECIMAL and it is the only literal in board_memory.zig that is - every other one is an address, and addresses come off datasheets and linker maps that print hex, which is why that file made everything hex two commits ago. A GPIO number is not an address, it is part of a NAME: the schematic says GPIO47, the datasheet's pin table says 47, and `Gpio 20` meaning pin 32 would be a trap laid for the one argument anybody types from memory. ## The toggle is the host's, not the editor's New `Host.VTable.pull_gpio_toggle`, and a `GpioFn` in the p4 ABI (hence version 2), rather than board_memory reaching for GPIO_OUT the way `Poke` two functions above it would happily do. Writing that register is not the job. A pad has to be pointed at the GPIO function in the IO MUX, routed in the GPIO matrix, given drive strength and an input buffer with its pulls cleared, and only then driven - four register files behind a per-pin table. That code already exists in `05-zig-p4/src/hal/gpio.zig`, it is the same `configureOutput` the blink demo has always used, and its register numbers are checked against ESP-IDF's own headers on the die by `zig build diff`. A second copy inside the editor object would be a second copy under no test, and getting it wrong on a pin that boots as something else is how you lose the console you are typing on. Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the level this board is driving. A pad's input buffer on an unconnected header pin reads the air. ## JP1, read off the schematic rather than remembered The diagram is the vendor's own wiring, from sheet 2 "Expand IO" of `01-esp32p4-m3/docs/JC-ESP32P4-M3_schematic.pdf` - the only document that carries this mapping. The specification PDF's "Interface Description" page turned out to be a marketing render, and there is no board user guide; the chip datasheet has a package pinout, which is not a header. That sheet is a 872x1168 raster (`pdfimages -list` - the PDF embeds no vectors, so rendering it larger adds nothing), and at that size the rows around pin 14 are genuinely ambiguous by eye. So the mapping came from the drawing's geometry instead: thirteen wires leave each side of the symbol, a net wire runs ~100 px to its label and a power stub ~21 px. Pin 8's wire is 21 px, which is what identifies it as unconnected rather than as the first of the GPIO4x labels - the reading that had GPIO47 one row higher and shorted GPIO45 to the ground bracket. Cross-checked against a second source that has been in the tree all along: `05-zig-p4/build.zig` documents `-Dled=20` as "JP1 pin 17", and GPIO20 lands on pin 17 here. Both facts are asserted in the test, so the diagram cannot drift from either. ## Peek, Poke, Hexdump and Gpio are now the P4 build's alone `board_memory.enabled` was `os.tag == .freestanding and !isWasm()`, on the argument that these words are a property of having no operating system rather than a product configuration, and that a predicate spelled out of `builtin` cannot drift the way a hand-maintained enum can. Tidy, and it answered the wrong question. A word only exists if some shell offers it, and the shells are the platforms. `Gpio` settles it beyond argument: its whole content is one board's header, and a second freestanding port would need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, "this board" was, and the two only looked identical because there is currently one of them. The old predicate's real work was excluding wasm - `freestanding` too, where an address is an offset into a linear memory the engine owns - and naming `p4` excludes it by construction instead of by a term somebody has to keep remembering. The target is now the witness rather than the gate. Absent means not compiled: the tty binary contains no `+Gpio`, no `+Hexdump`, no `ES_I2C_SDA` and no `MisalignedAddress`. ## The boot buffer's lines are checked, not eyeballed Three times now a line in that tour has been one or two characters too long for a 56-column grid, and every time it was found by reading the die's screen - the expensive way to measure a string literal. The text is a named `boot_buffer` with a test over it, six lines came down to fit with margin, and the tour gained `Gpio`. Tests: the pinout's width, its thirteen aligned pin rows, GPIO20-on-17 and pin-8-unconnected; the decimal-versus-hex distinction; every boot-buffer line. Full suite green - unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check - and tty, p4, gui, p4 at 80x24, p4 with the fade forced on. On the die `p4-bench --check` is 5/5, the fifth being a new one: three `Gpio 33` runs must report 0->1, 1->0, 0->1, because the alternation is the only oracle a hardcoded string could not fake.
* Make the chrome fade a build option, and compile it out for the boardGabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars - from the old colors to the new ones over ten display frames. On a screen that repaints in microseconds that is a short legible transition, and it is why the code exists: a palette that teleports reads as a glitch. On a 115200 serial line it is not a fade. Each of the ten steps recolors every anchored cell, so the diff finds the whole chrome dirty and spends a frame's worth of wire on it, ten times over, with nothing else on screen to look at. Measured on the die, one `NextColor`: fade on 12,593 bytes 1,097 ms of saturated wire fade off 2,425 bytes 215 ms A second of the editor talking to itself about a color, on the one transport where a second is noticeable, for a gradient nobody can watch arrive at 11.5 KB/s. ## Comptime, so the code is not there `ChromeAnimation` now selects between `animation.Transition` and a new `animation.Immediate` - the same interface with the animation taken out, a value that is only ever what it was last set to. That is what makes `ChromeTheme.interpolate` unreachable, and unreachable is what makes it absent: the flashed image drops 2,336 bytes, and the object 13,180. A bool tested at runtime would have kept every one of those bytes and still paid the branch. It also would have needed a second meaning bolted onto `animate_theme_changes`, whose job is the startup window and nothing else; that field is untouched here. The option is `-Dtheme-animation`, defaulting to off for `p4` and on everywhere else, and it is an option rather than a platform test because "is a frame expensive" is a property of the transport: a P4 driven over something faster than a UART would want the fade back, and `-Dtheme-animation=true` gives it to them. ## What was checked `Immediate` is new code with one contract worth pinning, and it is the one a caller could get wrong: it must arrive at the SAME palette a completed fade arrives at. An endpoint that differed by a rounding step would make the option a change of colors rather than a change of how long they take. Tested against a fully advanced `Transition` in `animation.zig`. Full suite: unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check. Builds: tty, p4, gui, and tty/gui with the fade forced off. On the die the canonical verifier reports the screen IDENTICAL across both arms - the workload contains no theme change, so this is the check that ordinary rendering was not perturbed - and `p4-bench --check` stays 4/4.
* Drop the 0x from what Peek, Poke and Hexdump printGabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Two columns back, and a better reason than the two columns. Every number these words read is hex - there is no other kind, and they refuse a decimal one - so a prefix on the output restates what the whole file already says. Dropping it buys something worth more than the width: an address in a dump can be typed straight back into a Peek without editing it, because bare hex is exactly what the parser now wants. Output that is valid input beats output that is decorated. No platform question to answer either: `enabled` is freestanding-and-not-wasm, so these three words exist only on bare metal. There is no host format to stay consistent with. On the die, 44 columns of a 48-column body: 40000020 32 54 cd ab 00 00 00 00 |2T......| 40000030 30 2e 31 00 00 00 00 00 |0.1.....| 5011002c: wrote deadbeef, reads deadbeef 5011002c: deadbeef 501101a4: fc48777d 501101a4: 4b4ae238 The last two are the same command twice - LP_SYSTEM_REG_RNG_DATA, which is what makes it the honest demonstration that a register is not memory. unit-test, and `p4-bench --check` 4/4 on the board.
* Fit Hexdump to the board's width, and cut the boot buffer down to addressesGabriel Schneider2026-08-26
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | ## Eight bytes a row on the P4 `hexdump -C`'s sixteen needs 79 columns: ten for the address, forty-eight of hex, a gap, and eighteen of ASCII gutter. The board drives 56 columns of which seven go to the line numbers, so every row wrapped onto a second display line and the columns stopped lining up - which is the entire value of the layout. Eight fits in 46 and keeps every property that matters, including a gap at the halfway mark, because the eye counts in fours and eights rather than in sixteens. Verified on the die: 0x40000020 32 54 cd ab 00 00 00 00 |2T......| 0x40000030 30 2e 31 00 00 00 00 00 |0.1.....| That is the app descriptor: 0xABCD5432 and the version string, read out of flash by a command typed with no 0x on either argument. ## The boot buffer is shorter, and its addresses are named The first draft opened with four lines of prose explaining that there is no operating system. True, unhelpful, and it cost a third of a fourteen-row window before the first command. One header line earns its place; the rest of the screen is addresses. The two LP registers at the end are now named, because they are named in ESP-IDF's own headers and the names are the interesting part: 0x5011002c is LP_SYSTEM_REG_LP_STORE0, a general-purpose retention register that holds what you put in it, and 0x501101a4 is LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate the whole point of a volatile read - one address gives back what was written, the other never gives the same answer twice: Poke 5011002c deadbeef -> 0x5011002c: wrote 0xdeadbeef, reads 0xdeadbeef Peek 5011002c -> 0x5011002c: 0xdeadbeef Peek 501101a4 -> 0x501101a4: 0x0b099791 Peek 501101a4 -> 0x501101a4: 0xfc97f3b7 All four run on the die, all with bare hex. Peek and Poke had not been tested there before this - only Hexdump had, which I had let stand as though it covered all three. snap 95/95, hxdiff 481/0, hxparity 561/0, unit-test, tty/p4/gui.