summaryrefslogtreecommitdiff
path: root/src/pardes.zig
Commit message (Collapse)AuthorAge
* Draw a frame only when there is one worth drawingGabriel Schneider2 days
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A 9P round trip on a local Unix socket cost 9.7 ms against the SDL shell and 0.758 ms against the terminal one. The server was not slow and the wake was not broken: instrumenting the path showed every request waking the loop early (wakes=210, woke_early=212, timed_out=38 over 250 ticks) and being answered on that same pass. The cost was that `pump` answers 9P at one point in a loop that then renders and presents unconditionally, so a client's next request landed while the main thread was blocked on the display, and each round trip therefore cost a whole frame. The frame rate was governing something that has nothing to do with drawing. `Pardes.needs_frame` starts true, is set by every event except a tick with nothing animating and a filesystem request that only reads, and is cleared once a frame is presented. `pump` returns before render and present when it is false and nothing is animating. An idle editor answering reads now draws nothing at all. 9P read_fid, one RPC: gui 9.7 ms -> 0.056 ms (173x) tty 0.758 ms -> 0.062 ms (12x) Verified the shells still paint rather than going quiet: the rendered screen carries the opened file, a write through 9P redraws within the frame, and `fs-discovery-test` passes over the real wire. Suite unchanged at 778/783 with the two pre-existing crashes. Also from the adversarial review of the previous commits: `pardes --tty FILE` silently discarded the file, and `--tty MISSING` silently discarded the error pane. main.zig named the boot layout before the positional was resolved, and naming one short-circuits `Boot.of`. The choice now happens after the argument is known, and only when there is no file and no missing word. macos.zig names the same layout, so the app no longer boots a different one from the terminal and SDL shells. `pre_close_last_pane_tail` was transcribed from the NEW default rather than the old one, so the upgrade path it was added for did not exist: a workspace dumped before the tagline reorder came back with the old default welded on as a custom tail. It is now the string it claims to be. A pane two rows tall lost its message and, worse, its prompt and the cursor with it. The notice cap keeps the LAST notices now, because the prompt is last and a prompt you cannot see is one you type into blind. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Stack a pane's transient lines instead of letting the last one win the rowGabriel Schneider2 days
| | | | | | | | | | | | | | | | | | | | | | | | | | | A pane's message, its pending leader chord and a prompt waiting for input all wanted the same row above the tagline. The prompt won it, the leader drew over whatever was there on the right, and renderBodyLayer recomputed "is anything down there" inline to reserve a single row. Three claimants, one row, and two places deciding. Pane.Notices is that decision in one place: a short ordered list of the lines a pane is showing, rebuilt every frame by collectNotices from the state that owns each one. The body layer reserves exactly notices.len rows, the way it already reserves rows for sticky context headers, and the paint pass walks the list and gives each line a row of its own, stacked upward from the tagline. Nothing stores a second copy of the truth, so a line that goes away is simply not added next frame and the rest close the gap. One notice lands on exactly the row the message always had, and the prompt stays nearest the tagline so it keeps its cursor. Last also learned what to do when the jumplist is empty. It used to walk the jumps and, finding nothing, do nothing at all -- which is the ordinary case for a pane that opened beside this one and was never focused, such as the text pane the bare tty layout puts under the shell. It now falls back to neighbourPane: the next pane down the column, wrapping, and any other live pane failing that. Alternating with the neighbour is what Last is for. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Merge the macOS first-class-host workGabriel Schneider2 days
|\
| * Make the macOS shell a first-class hostGabriel Schneider2 days
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Pty children are exec'd with their own TERM/COLORTERM/TERM_PROGRAM instead of inheriting a .app launch's empty environment, and ttyTaken finally answers on darwin — libproc walks the tty's foreground process group — so Escape reaches the child and Exec stops believing every pane sits at its prompt. The occupancy suite runs on both platforms now. The workspace tag row moves into the native menu bar as a Builtins menu. -Dworkspace-tag (default off for -Dplatform=macos, on everywhere else) drives it, and Pardes.topBarHeight replaces the TOPBAR_H constant so the core stops reserving the row. The view pins every variable-font axis to the file's own default (Maple Mono came up Thin otherwise), shapes ligatures, carries per-shape pointer cursors, and draws the look-hover affordance as refracted glass. Tag rows fill edge to edge, with the anchor box painted back on top of that fill and its mode glyph centred on the same square. Theme accents re-saturated across the set. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* | Plan 9 idiom for the control filesystem, and the regressions a624a56 leftGabriel Schneider2 days
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The 9P tree stops being a command language wearing a filesystem. /new created a pane as a side effect of a *read*; it is now Tcreate in /pane, with Tremove to close, which cloud9's engine has always supported and the editor never declared: tree.zig now says `features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs become files that can be read as well as written -- dot, limit, dirty, mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file would say better. Root /ctl splits into a read-only /status and the /look and /exec files whose write IS the click. stat carries real sizes where it used to answer 0, and qid versions track a pane's revision, so a client can poll for change without re-reading the body. Commit a624a56 moved raw-tty keys to an early-return branch that knew only Ctrl-B and bare Escape, and in the same edit deleted the paste branch below it. That cost Shift-Escape (the unconditional way out of tty mode) and both paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an agent CLI running in a pane took Ctrl-V for its image-paste binding and answered "No image found in clipboard". Both are restored, with tests. Nested detection was not subtly broken but deleted: 60367d8 removed nested.zig's process-ancestry walk and left "am I inside pardes" derived from PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener did not come up". PARDES_PID now answers that question on its own, checked with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag is gone. The posted-9P registry also self-heals now -- a session that aborts cannot unlink its own socket, so posting sweeps entries whose target refuses a connection, symlinks only and on a definite ECONNREFUSED only. Elsewhere: tty scrolling is sticky-bottom, following new output only from the last row, with typing and entering raw mode snapping back to live; the boot layouts are a Boot enum instead of a chain of ifs, and the bare tty startup (Boot.tty, which main.zig names) opens an empty text pane under the shell while tests keep Boot.tty_shell; builtins announce themselves on the message row under a Verbose setting that is on by default; Config prints each setting the way you would type it back, so WindowOpacity 70 rather than "WindowOpacity: 70%"; LocationsConfig opens its window only when called bare; every tagline puts the word that closes the thing last, and a column now outlives its panes -- closing the last one leaves an empty pane, and only Delcol, newly on the column tagline, takes the column away. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Use cloud9's file-server engine instead of the private copyGabriel Schneider2 days
| | | | | | | | | | | src/9p.zig shrinks to an 88-line alias: the engine and the backend contract (Req, Reply/ReplyWith, Op, Status, Attr, E, error strings) now come from cloud9.fs. The editor's limits become cloud9.fs.Options values; the ESP32-P4 board backend drops its own copies of the contract types. No behaviour change; fs-bench still allocates nothing per request. cloud9 re-pinned to the commit that carries the engine. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Flatten the 9P control tree and move it out of fs.zigGabriel Schneider2 days
| | | | | | | | | | | | | | | The served tree loses the self/ level: /index /ctl /new /log /screen /listeners /pane/<n>/... /os, with /src only in -Dembed-sources=true builds (default off, on for esp32p4). ctl speaks the editor's own language with two lowercase verbs, look TEXT and exec TEXT, plus acme's addr verbs; the new/ factory directory becomes one clone file; cons is gone (exec Msg); name and sel are files; stats report real lengths, modes and mtimes; /log streams pane new/del/rename/save events. The tree code lives in src/ninep/ (tree, pane, ctl, addr, pty, events, screen, sources); fs.zig keeps host access, mounts, resolution and find/grep. Same engine and transports. README (fs-help.txt) and docs rewritten; tests updated and extended. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Add macOS backdrop blur and preserve PDF ink opacityGabriel Schneider2 days
|
* Follow the tag gap in the Look hover preview testGabriel Schneider2 days
| | | | | | | | | | The tag's first character moved to TAG_TEXT_INSET when the grip's hit area was separated from the text by a gap; the assertion kept reading GUTTER, which is now the gap itself and carries the plain tag background. The test's own point — that a preview on the tag's first character is paintable at tag column zero, PREFIX_W being a body concern — is unchanged; only where column zero is moved. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Give macOS scrolling momentumGabriel Schneider2 days
| | | | | | | | | | | | | | | | | | | | | | | | | | | | A swipe that was still moving when the fingers lifted stopped dead. The dial next to it has thrown properly since pardes_rotate_end, and this is that same curve on the scroll accumulator: velocity sampled between events off the monotonic clock, weighted toward the newest sample because a flick is decided by how the hand was moving when it left, and a fling that ramps up from zero at the floor rather than switching on at it. A coast stops at the end of a document rather than spinning its remaining velocity against the edge, which is why spendScroll now reports whether the pane moved and why paneAt is public. Only a step that delivered a press can report an edge — the many steps between two rows cross nothing. Nothing suppresses AppKit's own momentum, and nothing needs to: its momentum events are ordinary pardes_scroll calls, every one of which cancels the coast before spending its travel, so on a real trackpad the system takes the gesture over about a frame after the lift and the tail it ends on is below the floor. This is the path for devices AppKit does not fling for — and the only one a script can reach, since NSEvent phases have no public constructor. momentum.snap asserts both halves, which is why it needs the long scrollback: a flick with no document left proves nothing. A slow swipe is byte-identical after the release; a hard one coasts twenty-four rows further on its own, and a finger back on the pad stops it there. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Clear selections when navigating Back and ForwardGabriel Schneider2 days
|
* Keep unavailable numeric Look targets harmless when clearing selectionGabriel Schneider2 days
|
* Clear previous selections before resolving Look destinationsGabriel Schneider2 days
|
* Reuse bounded source analysis and speed up result context traversalGabriel Schneider2 days
|
* Default location results to stacked addresses and source previewsGabriel Schneider2 days
|
* Separate pane and column grip hit areas from tagline text with a small gapGabriel Schneider2026-09-15
|
* Keep column grips blank and muted in every focus stateGabriel Schneider2026-09-15
|
* Cycle pane input modes with Mode and retain terminal tag accessGabriel Schneider2026-09-15
|
* Reorder columns using aligned and distinctly colored tag gripsGabriel Schneider2026-09-15
|
* Make location highlighting an explicit output producer choiceGabriel Schneider2026-09-15
|
* Separate tag text geometry from physical pane gripsGabriel Schneider2026-09-15
|
* Use result metadata when walking output locations with n and NGabriel Schneider2026-09-15
|
* Separate discontinuous pinned source context with thin bordersGabriel Schneider2026-09-15
|
* Align search and LSP context locations flush leftGabriel Schneider2026-09-15
|
* Match filename tint lightness in every canonical theme and focus stateGabriel Schneider2026-09-15
|
* Add optional compact tagline styling for source contextGabriel Schneider2026-09-15
|
* Lead terminal tags with a tinted Tty commandGabriel 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
|
* 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.
* 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
* 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
* 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
* 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.