summaryrefslogtreecommitdiff
path: root/docs
Commit message (Collapse)AuthorAge
* docs: a 9P design note and a design registry to argue it inGabriel Schneider2026-08-27
|
* An edited row keeps its colours, four copies of forkShell become one, and ↵Gabriel Schneider2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc stops recentring ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
* tests: a capture is a delta and a click names its word, so a tagline edit ↵Gabriel Schneider2026-08-25
| | | | stops rewriting the suite
* acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSEGabriel Schneider2026-08-25
|
* builtins + config: Save reaches every pane holding text of its own, and ↵Gabriel Schneider2026-08-25
| | | | takes a path argument
* syntax + pdf_pane + file_pane: prose grammars paint, pdf Esc cancels, gj/gk ↵Gabriel Schneider2026-08-25
| | | | walk wrapped rows
* host: the core owns the event loop; every platform becomes a vtable of ↵Gabriel Schneider2026-08-25
| | | | optional methods
* animation: six character-motion panel transitions, composed in the core so ↵Gabriel Schneider2026-08-25
| | | | backends agree
* gui: rendering rework, web css, config doc trimGabriel Schneider2026-08-18
|
* term_pane + builtins: terminal pane work, builtins/config additions, snapshotsGabriel Schneider2026-08-18
|
* nested + pardes: snapshot updates and small behavior fixes across panesGabriel Schneider2026-08-18
|
* file_watch + builtins + config: richer watch semantics, new builtins, config ↵Gabriel Schneider2026-08-18
| | | | docs
* modal + file_pane: rework editing math and pane behavior, config/syntax ↵Gabriel Schneider2026-08-18
| | | | additions, unit tests
* animation: core publishes transition records; gui evaluates via shaders, tty ↵Gabriel Schneider2026-08-18
| | | | over grid cells
* big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web ↵Gabriel Schneider2026-08-18
| | | | + snapshot refresh
* look: richer path/range parsing, pdf rendering, corner-drag and stepgrain ↵Gabriel Schneider2026-08-15
| | | | snapshots
* shaders: -Dprebuilt-shaders, so a gui build needs no Vulkan SDKGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | glslc is the one build input that wants a tool a stock machine does not have, and it is also the input that changes least often: eight GLSL files that have outlived several rewrites of everything around them. Asking every machine that wants to run the SDL shell for shaderc is the wrong trade. The SPIR-V is now COMMITTED, under shaders/prebuilt/, and -Dprebuilt-shaders embeds that copy instead of shelling out. The default stays the honest one -- compile the shaders that are actually in the tree -- because the flag trades a dependency for a freshness problem: with it on, the .glsl sources are not build inputs at all, so editing one changes nothing. `zig build shaders` is the other half, and it is deliberately independent of -Dplatform: it recompiles every shader and writes the result back into the tracked directory, so whoever changes a shader refreshes the cache on a machine that has the compiler and commits the diff. `jj diff shaders/prebuilt` after it is the freshness check -- empty means the cache was already current. The shader list is also spelled once now (gui_shaders): the eight embeds, the eight glslc runs and the refresh step all read it, so adding a shader is a name there plus the @embedFile in gui.zig, not three edits in two places. Verified: -Dplatform=gui -Dprebuilt-shaders builds with glslc absent from PATH, and image-harness passes on that binary -- real SDL GPU pipelines built from the committed SPIR-V, 512 source pixels read back. The default gui build still runs the eight glslc steps; tty runs none. The committed bytes are identical to a fresh glslc run, and `zig build shaders` is idempotent.
* docs: the tutor taught three keystrokes wrong, and the rest had driftedGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The documentation had gone stale in the ordinary way -- claims that were true when they were written and that nothing since had been obliged to re-read. Some of them were load-bearing. THE TUTOR. It still said there is no multi-cursor, that NextColor cycles three themes, and that its practice blocks "are also run as unit tests (generated from this file by tutor_gen)" -- a tool that appears nowhere in the tree, and nothing anywhere parses a `# keys:` block. Left alone, that claim is what makes the next wrong block survive. Three of those blocks WERE wrong, and all three for one reason: since the helix motion model landed, w/e/f/t SELECT the range they cross, so `i` after one inserts at the SELECTION'S START. `w i Z esc` on "foo bar" gives "Zfoo bar", not the "foo Zbar" the file promised. They were written against a vim reading of the same keys. Every block in the file has now been run through `zig build hxdiff` against the real core and matches byte for byte, and the trap itself is written down in 3.3 rather than left to be rediscovered. The tutor gains a PART 4 for everything added since it was written -- PDF panes, the in-process ZLS backend, themes and fonts, the startup file -- and PART 3 gains counts (and which keys ignore one), f/F/t/T, the whole g table (bare `G` is a no-op; `ge` is the START of the last line), multiple cursors and the s/S regex pair, `m`, `]`/`[`, `|`, insert mode, and all fifty leader paths. THE REST. design.typ's line table claimed 7,626 lines against a real 38,048, and its rows did not sum to its own total; its Event/Effect boundary contract -- the part a shell author writes against -- named four variants that do not exist and omitted fourteen that do. lsp.md's probe count. config.md's theme-name rules, which as written could not reach a zed theme at all. helix-keys.md's Skipped section, holding five families that have since landed. macos.md's menu bar, undocumented, along with sixteen other claims. web.md on what the browser build can actually do. SOURCE COMMENTS that had rotted alongside them: `tag_normal` is a space, not the `•` its own comment describes; Wrap is ON by default, not off; a FontSel row is SELECTED by n and RUN by Tab, not run by n; the SPC paths in lsp.zig lost their `l` group prefix when the language group moved; and the differential suites are 481 and 561 cases, not 360 and 440. TWO THINGS FOUND BY DOCUMENTING THEM, both left standing and written down rather than papered over. Typing `[^\n]` at an s/S prompt panics: the live preview compiles every prefix, and `[^\` indexes an empty slice in mvzr's parseCharSet. Both the tutor and a waiver recommended that pattern as the workaround for `.` matching a newline; they now say what it costs and what would make it sayable. And `Exec` is a builtin, so an `Exec` line in the startup config types that command into a shell before the first frame -- the tutor said nothing in that file is ever sent to one. Nine adversarial reviews over two rounds, each with the hxdiff harness to execute what it doubted. The second round exists because the first round's fixes needed checking too, and it caught three regressions of my own -- one of them a probe count I had "corrected" away from the truth. Verified: unit-test, snap 87/87, hxdiff 481/0, hxparity 561/0, mupdf-check. docs/design.pdf regenerated. The tutor's first seventeen lines are byte- identical, which is what tutor.golden pins.
* clipboard, n/N and the tty prompt: three things that were half-wiredGabriel Schneider2026-08-12
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three changes that all turned out to be the same shape -- a feature that worked in one direction, or for one pane kind, and quietly did not in the others. CLIPBOARD. Every register write emitted set_clipboard, so deleting one character threw away whatever the desktop was holding; multi-cursor yank took the join's early return and emitted nothing at all, so the same key reached the clipboard on one cursor and not on two. Nothing could READ the clipboard: the SDL shell had no SDL_GetClipboardText anywhere in it, and the tty shell never asked for OSC 52, so `p` from another application was dead in both. Now it is helix's split. y/d/c/p/P/R and the acme chords are the DEFAULT REGISTER and nothing else; the system clipboard is five words on helix's own letters -- SPC y, SPC Y, SPC p, SPC P, SPC R -- spelled as builtins so they land in Help and are executable like every other verb. The one exception is the tag `y` chord, which still mirrors out because a tag is always insert, so SPC cannot be pressed there, and copying the path out is the whole point of the chord. Reading is a new read_clipboard effect answered by an ordinary Event.paste, so the round trip is honest about being one: SDL and NSPasteboard answer inside the same drain, the browser answers a promise, and a terminal answers over OSC 52 or -- far more often -- refuses. A refused read is a paste that does not happen, and the request dies at the next keystroke rather than landing minutes late in whatever pane is focused by then. The tty shell also enables BRACKETED PASTE now and coalesces paste_start..paste_end into one event. Before this a paste arrived as a flood of individual key presses: plausible in insert mode, and in normal mode every pasted character ran as a command. n/N. They stepped the armed results buffer and immediately Looked each row, so you could not walk past a hit without opening it. They are a MOTION now: select the next look-able text, open nothing, and let Enter decide. What they step is the largest whitespace-delimited run look.resolve can act on (look.lookableSpan, wrapper punctuation peeled), over a RING of panes -- every pane that has performed a look, most recent first, then the output buffers that have not, newest first, and only if both are empty the pane in front of you. N is the exact inverse of n, computed rather than remembered: both directions ask the same question about the same spans and compare against the column the walk parks on, so x presses one way and x back land exactly where you started, pane boundaries and the ring's seam included. A ring rather than a list with two ends because a shell's cursor sits at the prompt, below everything it has printed, so a walk that could not come round would have nowhere to go on the very first press -- which is the case n/N were written for. One motion everywhere, no pane-kind or buffer-kind special case. The only thing a buffer may change is the GRAIN of what a step selects, and it does it with one flag rather than a branch: output_pane.Traits.commands (renamed from `executes`, which named one reader's behaviour rather than the fact) makes a row select WHOLE, because a ThemeSel line is a word to run and has no path inside it to pick out. `]d`/`[d` are not n/N -- they are helix's diagnostic motions, their job is to ARRIVE, and they still reach searchStep. THE TTY PROMPT. Leaving raw tty blanked the prompt row, and the command you had typed at that prompt shares the row, so it went too -- a shell out of tty read as output only. OSC 133 marks the row CELL by cell, so the two are separable: config.tty_blank = .prompt cuts the prompt's own columns and leaves the command, left-hugged at column 0 in line with the output under it rather than in a bay of blanks. .prompt_and_input is the old behaviour, kept. Because the row is now something you can put a cursor in, enterTty adds the hidden prompt width back before asking ghostty to walk the shell's own cursor to it -- the modal column on a cut row is short by exactly that much. Verified: unit-test 186/186 (nine new), snap 87/87 (new ttyprompt.snap), hxdiff 481 and hxparity 561 with 0 mismatches, tty and gui both build. And against the real binaries rather than the harness: in a pty, SPC y emits OSC 52 carrying exactly the selection while plain y emits nothing, SPC p issues the read and pastes the reply, and a bracketed paste of "dd..." inserts text instead of deleting two lines. In a real SDL window, SPC y then SPC p round trips through the system clipboard while the default register holds different text. Setting tty_blank back to .prompt_and_input reproduces all 86 old goldens byte for byte.
* macos: pixel attachments, live theming, and a signed appGabriel Schneider2026-08-11
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The AppKit shell now draws what the core renders, follows the theme without a relaunch, and builds into something you can hand to someone. - Pixel attachments. Surface.images was dropped on the floor here, so a PDF pane showed nothing at all: native_images is now set, pardes_image_s carries the geometry the core already clipped, and PardesView keeps one CGImage per (serial, page, revision) so scrolling costs a draw and not a decode. Image panes get real pixels instead of the petscii fallback. - Themes take hold live. pardes_tick never advanced the chrome animation, so every tagline kept the previous theme's colours until the next launch and the 16 ms re-pump spun for the rest of the session. pardes_theme_bg retires the hand-agreed #121212 and drives the window background and the titlebar appearance; a theme with no background of its own now gets a transparent window over an NSVisualEffectView. - The cell snaps to whole DEVICE pixels rather than whole points. Monaco advances 8.4014pt at 14, so ceiling to 9 spaced every column 7.1% wider than the face was drawn for. - The dial is one notch per 10 degrees instead of 20, and a release keeps turning in proportion to how hard it was thrown -- ramping up from zero at the floor, so a slow twist coasts not a little but not at all. - A file dropped on the grid is a click plus Look, so it opens beside the pane it was dropped on. No drop concept was added to the core. - The titlebar follows the focused pane: proxy icon, filename, and the dirty dot. File.saved_revision is the watermark that last one needed. - Config (SPC f c) prints the resolved startup config path. - build.zig assembles, signs and packages the bundle itself; build-app.sh is gone. -Dmacos-identity= takes a Developer ID, macos-dmg makes the image, and the icon is Glenda.
* macos: the AppKit shell, its icon, and the offscreen e2e harnessGabriel Schneider2026-08-11
|
* fixed lsp renameGabriel Schneider2026-08-10
|
* fix tab rendering - waybe wrecklessGabriel Schneider2026-08-10
|
* Better text renderingGabriel Schneider2026-08-10
|
* lsp rows: paths relative to the asking file, and the completion textGabriel Schneider2026-08-10
|
* Tab after a dot in insert mode lists what could go thereGabriel Schneider2026-08-10
|
* Esc alternates between the last two panes; Toggleterm is goneGabriel Schneider2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Esc ran Toggleterm, which hopped between the newest DOC and the newest TERMINAL. That distinction never earned its keep. It made Esc unpredictable — which of three panes you landed on depended on their kinds, not on where you had been — and it could not alternate between two files at all, which is the case you hit most. Editing two files, Esc did nothing. The replacement already existed. Last (SPC j j) is "the pane you were in before this one, whichever it was": it walks the jump stack for the newest entry naming a different pane and restores its line and column. So Esc, and Shift-Esc in tty, now run Last, and Toggleterm is deleted rather than renamed — a third implementation of "go to the other pane" was the thing to avoid. SPC w t goes with it; the w group is the four directional moves, and the jump group already had SPC j j. Held down, Esc alternates. Two files, a file and its shell, a file and a +Search — all the same, because Last has no notion of kind to get wrong. This depends on the swap in the same series: Last reads the stack backwards, and until hopping stopped appending, the pane you came from could fall off it. windownav.snap needed only its keys and prose changed — its golden did not move at all, which is the useful evidence here: for the one scenario the old builtin handled well, Last produces an identical focus sequence. Coverage for what it did not handle is new: a unit test opens a second FILE by looking its name and asserts Esc alternates between two panes of the SAME kind, which is the case that used to be a no-op. Docs follow: tutor.txt, docs/helix-keys.md, docs/design.typ, and the builtin index goldens, which are now one row shorter. 75/75 snapshots, both unit suites, and the macOS ABI build all pass.
* a native macOS backend: libpardes plus an AppKit shellGabriel Schneider2026-08-10
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Adds -Dplatform=macos, a fourth backend beside tty, gui and web. Zig keeps the core, the ptys, every effect and the worker threads; Swift owns NSApplication, the window, input translation, and drawing the cell grid with CoreText. They meet at a hand-written C ABI in src/macos/pardes.h, built as a static library the app links. The ABI is src/web.zig's boundary with the wasm removed, because both hosts are the same animal: someone else owns the clock, feeds events in through flat functions, and reads one packed cell buffer out. The browser proved the shape. The one divergence is that the browser has no processes and forwards every effect to JavaScript, whereas forkpty is right here, so src/macos.zig performs them — spawn, write, resize_pty, save_file, new_file, write_dump, open_link, set_clipboard. lsp, pipe and watch are answered with nothing and marked; the core already tolerates that, since the browser answers none of them either. This deliberately inverts ghostty's split, which was studied first and is written up in docs/ghostty-macos-notes.md. Ghostty hands Zig a bare NSView*, installs its own CALayer and owns the frame clock; Swift never renders. Pardes does the opposite because its frame is already a cell grid and CoreText draws one natively — the alternative is a second hand-rolled glyph atlas, which is what most of gui.zig's 4,300 lines already are. It would also have been written blind: the Swift half cannot be compiled here. What makes the scaffold verifiable rather than dead code is that the Zig half is ordinary POSIX and builds and tests on Linux. Borrowing ghostty's best trick, build.zig translate-C's the header into the test build and src/macos.zig asserts every constant, struct layout, and exported function's arity and widths against it. That guard earned its place immediately: pardes_scroll grew a cell coordinate after the Swift view had been written against the older form. Skipped, and named as the upgrade path in docs/macos.md: the Xcode project, xcframework, lipo and codesigning ghostty needs. All four exist for distribution; a dev build is a swiftc invocation and a directory with a plist. The Swift app is a scaffold and says so — every uncertain API spelling carries an UNVERIFIED marker, and no part of it has been compiled. tty is unaffected: 75/75 snapshot scripts and both unit suites pass.
* show New in every pane tag and enforce builtin-first Exec fallbackGabriel Schneider2026-08-10
|
* prioritize useful topbar commands and put Kill lastGabriel Schneider2026-08-10
|
* add New builtin for an empty temporary file in the calling columnGabriel Schneider2026-08-10
|
* load builtin commands from the user config before renderingGabriel Schneider2026-08-10
|
* normal Esc toggles document/terminal focusGabriel Schneider2026-08-10
|
* add vanilla DOM web backendGabriel Schneider2026-08-10
|
* a Look path can name a range, and search selects what it foundGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | file:LINE:COL-ENDLINE:ENDCOL, with the two short forms people actually type reading naturally: file:412:9-21 on one line, file:412-418 whole ones. Ends are inclusive. A path feature, not a search feature — a ranged path typed in a tag or middle-clicked out of a shell's output selects just the same; search is only its first consumer. The dash is the fussy part. `-` was already a file char, so a ranged word survives click expansion whole, but a range needs a number on BOTH sides or my-file:10, build-2 and 2026-07-30 would stop being paths. Table-driven test in look.zig for exactly that. Selecting goes through the cellRange/setPaneRange pair the multi-cursor work left, and hxOff clamps both ends, so a stale range selects what still exists rather than crashing or reaching past EOF — pinned with an 8:6-400:9 range in a nine-line file. Producers: / search, Grep, and five LSP sites through a new spanRow — goto, references, rename tokens and both symbol lists were throwing away real protocol ranges at path:line:col. Left alone deliberately: Find rows are bare paths with nothing to span, a jump is a spot not a span, and the diagnostic and format paths only ever have a point, where half a range would be worse than none. One knock-on worth knowing: n now leaves an EXPLICIT selection, so a topbar execute chords it. grep.snap's no-match step was silently becoming `Grep TARGET`; it runs from the leader path now, which never chords, and the dedicated chord steps stayed where they were.
* every helix and zed theme on the machine, not a curated nineGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 228 in the ring: 3 pardes ships plus all 214 helix runtime themes and all 11 zed variants across its three families. No exclusions. inherits is what this cost. 76 themes need it, so the reader is two passes now, and the merge is WHOLE-KEY rather than field-wise because helix merges the theme body at depth 1 — the three *_transparent themes clear their parent's background with an empty table, and a field-wise merge would leave it painted. I had that wrong until I read helix-view/src/theme.rs. At 214 files the generator meets themes that are SPARSE rather than broken, and the old fatal-on-anything-missing would have rejected them. Every fallback is helix's own rule: no background at all means wear the terminal's, so bg and fg go null (13 themes, correctly); the twelve RGB fields that cannot be null fall back to ghostty's default palette entries 7 and 0, which is exactly what a pardes terminal already paints an unstyled ANSI index as. fatal is kept for what genuinely cannot be read. Three real bugs surfaced only at scale: #ccc shorthand doubles the nibble (helix's rule, 58 colours), a quoted inline-table key silently dropped a selection colour, and four themes name a cursor background EQUAL to the page because they are about to reverse that cell — taking it painted an invisible move box. 0 degenerate themes out of 225 now: no fg==bg, no syntax colour on its own background, no invisible box. Collisions get an unconditional rule rather than a clever one: a zed variant is always <name>_zed, because both projects ship gruvbox, ayu and one, and a conditional tag would move a name when the other source changes. A comptime assert holds it. Cost: no-op build 0.257 -> 0.276s, a pardes.zig change 46.8 -> 47.5s, binary +0.98%. The generator does 217 sources in 54ms. NextColor stays and is no longer a way to REACH a theme — but the browse got better, not worse: the generated half sorts by name, so its neighbours are that theme's own family. theme.golden's fourth click moved from ayu_dark to acid, which sorting 225 names does; themesel gained a tail capture proving row 228 renders.
* multiple cursors, regex selection, and Ctrl-c commentsGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and sels[] holds helix's OTHER ranges. That split is why nothing moved at one cursor: with nsel == 0 not one line of the existing motion, operator, render or mouse code takes a different branch, which is what protects 800 differential cases and 67 goldens. paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's Selection::new (min width 1, sorted, overlaps merged, primary follows its range through a merge). An ordinary key runs the single-selection handler once per range, visited last-first so an edit never disturbs a range still waiting, and each finished pass is remembered as a distance from the END of the text, which an earlier edit cannot move — helix's change mapping without a change map. pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from the primary and stops the replay, which also closes the use-after-free window if it frees the pane. s and S reuse the / prompt wholesale rather than growing a second one: the pattern is typed into the tag tail, and every keystroke re-runs the match from the selection the prompt opened on, so the preview is live and Esc is just the empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size struct with no allocator — with 64 ops and 8 char classes per pattern, no case-insensitive flag (helix's smart case is done by folding a scratch copy), no captures, no multi-line anchors. The last two are the two waivers. Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides comment-vs-uncomment ONCE for the whole selection; replaying it would take that decision n times. Comment tokens are a table in config.zig keyed on the same extension syntax.zig picks grammars by. Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left the cursor one cell before where the append began. helix's restore_cursor can never walk past the origin; ours backed up unconditionally. hxdiff was green before AND after — the old one-selection contract could not see it. hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the harness contract now reports every range and its primary, omitted when there is one, so 359 of the 360 old goldens are byte-identical. The one that moved is o-count: helix's 2o really does leave two cursors and could not say so before.
* themes: one file each, generated from helix and zed, picked by stepping a listGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | Each theme is a .zig file of pure data and nothing enumerates them by hand — fold() walks the container's declarations, so a theme is a file and that is the whole registration. Field by field rather than a wholesale coercion, which makes a missing field a compile error that names it. tools/gen_themes.zig reads helix .toml and zed .json out of vendor/themes and emits one .zig each; build.zig reads that directory, so adding a theme is dropping a file in. Output goes to the build cache rather than the tree, so zig owns the freshness check and a deleted source cannot leave a stale theme behind. Vendored, not read from the genizah: the build stays offline. A source it cannot map is a hard error naming the file and the key, never a silently black-on-black theme. ThemeSel is the clever half. Traits gained an `executes` column, so n/N over that buffer hands the whole line to Exec instead of the leading word to Look — both arms the ordinary builtin, so a stepped row does exactly what the matching mouse button on it would. Stepping the list previews each theme live. The trait is a pane property, not an output-pane branch, so any pane whose lines are commands can opt in. Goldens: leader gains SPC t t; theme's fourth NextColor no longer wraps to helix because the ring is fifteen long. New: themesel.
* focus history is a stack of locations, and the Jumplist is that same stackGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | One container, not two: focus_hist (a stack of pane ids rebuilt every sync) is now jumps[] + a current pointer, and the +Jumps buffer is a RENDERING of that array — nothing copies it, nothing shadows it. prevFocus, Toggleterm, Look's directory order, Back/Forward, Last and Jumplist all walk the one list. A pane id is reused, so a location that only remembered an id would retarget after a respawn: panes now carry a monotonic serial and an entry whose slot holds a different serial is dead. trackJump compacts those out and fixes the pointer in the same pass. The push rule lives in ONE place and says: a location is worth remembering when you cannot see it any more — a different pane, or more than a bodyful of rows away in the same one. So hjkl never grows the list and 100G, a search hit and a goto-definition do. Ctrl-o/Ctrl-i walk it, SPC j j toggles the last two, SPC j l lists them. Ctrl-i IS Tab on a legacy host, where the binding simply never fires and Tab-executes is untouched; kitty reports them apart. SPC j o/i work anywhere. Two goldens moved, both the SPC ? Help listing gaining four rows.
* Look and Exec are builtins like any otherGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | Their only special feature is now the keys they are assigned. `Look main.zig` typed in a tag is the same look a right click is; config.look_cmd/exec_cmd point the two buttons and Enter/Tab at them, so retargeting Enter to Grep is one line. actOnSelection — a hand-written cascade with the builtin dispatch nested inside it — is gone, and the `button` parameter it threaded through five KEYBOARD call sites went with it. New syntax, spelled once in config.zig: @`ls -la` names a command to run rather than a file to open. Word expansion takes the quoted run whole, the way acme does for its own </|/> words, so a click inside one does not hand Look the fragment `ls`. And it nests: @`Look .` unwraps, re-enters the dispatcher, and looks at the directory. Guarded at depth 8, which is unreachable today (every re-entry strips a word or a delimiter pair, so the string strictly shrinks) and exists for a future syntax that does not shrink. leader_path is now optional: a builtin may have no SPC path when its shortcut is a key and a mouse button. New golden cmdword; the other 59 unmoved.
* structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into ↵Gabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | their own files The core now lies FLAT at src/ and every subdirectory is one backend, so a file being in no directory at all is what says it is core. Pane-kind bodies leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving it the layout, the event/effect machine and the generic render loop. Builtins are one struct each in builtins.zig, and the enum is folded out of the file's own declaration list at comptime — a zig file IS a struct, so the list of builtins and the builtins themselves are the same text. Adding one is writing a struct. Key paths deliberately stay one table for the config pass. Pure refactor: no golden moved.
* lsp: writer seam, ZLS introspection builtins, ctrl-click goto, SPC l groupGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | THE SEAM TAKES A WRITER. `lsp.query`'s `out` is a `*std.Io.Writer`, not a `*std.ArrayList(u8)`. The shell owns the buffer behind it (an `Io.Writer.Allocating`), so a backend never allocates the result, never frees it, and cannot get the allocator wrong — the invalid-free class of bug has nowhere left to live. It also deleted a parameter from five functions: they only ever took a `gpa` to allocate rows, and 0.16's unused-parameter error found every one. `lsp.row()` lost its allocator argument too. Since a Writer cannot rewind or be counted, `query` renders into a scratch Allocating first: the log wants an exact row count, and `explain` throws the rows away and prints narration in their place. INTROSPECTION. `SPC l i` (Lspinfo) and `SPC l w` (Lspwhy), in the `l` group that now holds every language command (see below). They exist because of the seam's own contract: a backend never fails loudly, which is right for an editor, but it makes a broken backend and a correct one that found nothing look identical from the outside. Every query now leaves a record — kind, file, offset, duration, row count, and THE ERROR `run` returned, which `catch {}` swallowed and which was visible nowhere. Lspinfo prints those, plus which ZLS is compiled in, which zig lib dir and whether it actually opens (the usual cause of "gd does nothing in std"), and what the backend answers versus refuses. It answers from ANY pane, including one with no file, because it is about the backend — which matters precisely when the pane you are sitting in is the problem; both shells now send status for a file-less pane. Lspwhy narrates the REAL resolution path. The trace is threaded through `goto` itself, so what it prints is the position context the analyser returned and the branch that actually stopped. A debug view that re-derives the logic beside it is one that can disagree with it. CTRL-CLICK IS gd. Mouse gained a `ctrl` field, set by both shells (SDL asked directly via GetModState rather than read off key-event bookkeeping, which a click with no prior keypress would miss). The flag rides the drag rather than firing on the press: a click does not place the modal cursor until RELEASE, so a query asked at press time would answer about wherever the cursor previously sat. A ctrl-DRAG still selects. The snapshot DSL gained a `ctrl-` button prefix (SGR bit 4, what a terminal sends and what vaxis decodes). test/snapshots/lspdebug.snap covers all three, including a PLAIN click in the same spot that must NOT jump — without it the test would pass on a bug that made every click a goto. Durations cannot live in a golden, so PARDES_LSP_NOTIME (set by the harness, like PARDES_DUMP) omits them. 58 snapshot scripts, hxdiff 360, hxparity 440, unit 46, gui build: all green. lspbench: 17/17, 0 false claims. THE WHOLE LANGUAGE GROUP LIVES UNDER SPC l. pardes keeps its own leader letters back. `SPC d` is Del again, `SPC k` is Kill, `SPC s d`/`SPC s r` are Dump/Restore and `SPC h t` is Tutor — exactly where they were before the language work touched them. The previous pass put the LSP commands on helix's bare `<space>` letters and moved pardes's builtins out of the way (Kill k->q, Del d->wc, Dump/Restore s?->f?, Tutor ht->T). That was the wrong trade. Those five are the most-pressed keys in the editor and predate the language work; an LSP command is something you reach for deliberately and can afford one keystroke more. So every LSP command keeps HELIX'S OWN LETTER and gains the `l` prefix: `<space>k` -> `SPC l k` (hover), `<space>d` -> `SPC l d` (diagnostics), r/a/h/s/S/D likewise. Nothing to re-learn but the prefix, and `Lspinfo`/ `Lspwhy` were already there. THE GOTOS ARE UNTOUCHED. `gd` `gD` `gy` `gi` `gr`, `]d`/`[d`, `]D`/`[D`, `=` and ctrl-click all stay exactly as helix has them — they never collided with anything, so there was never a reason to move them, and they are the ones you actually press mid-edit. leader.snap is restored to the pre-LSP script (its `key q` unmapped-key step works again now that Kill is back on `k`) plus one new step for `SPC l ?`. Its `SPC ?` root listing had to stop waiting on Restore: the full list grew to 33 rows and row 21 falls off the pane, so it watches an early row instead. DEPENDENCY IMPORTS NOW RESOLVE. `gd` on `@import("vaxis")` opens vaxis's root file; before, it silently did nothing while `std` worked perfectly. The asymmetry was not a wiring mistake. ZLS's uriFromImportStr answers exactly three ways: a relative `.zig`/`.zon` path from disk, `std` from `zig_lib_dir` (one directory, which we supply), and EVERY OTHER NAME only by running `zig build --build-runner` to discover the module graph. That last branch needs `zig_exe_path`, which this backend sets to null on purpose — so every dependency import returned `.none`. Confirmed twice over: in ZLS's source, and by `SPC l w` on the import string, which printed the STOP line naming exactly that branch. (The introspection builtin diagnosing its own backend on its first real outing is a decent argument for having built it.) We never needed a compiler for this: build.zig IS the module graph. It folds `root_mod.import_table` into a name -> root-source-file table at configure time and passes it as a build option; the backend consults it precisely where ZLS gave up. Correct by construction — a dependency added or renamed in build.zig cannot forget to update it — and it costs no subprocess, no build step and no runtime work. `SPC l i` now lists the table, since "is this name even importable" is the first question when a jump does nothing. Two limits, both stated in the code: a module whose root is a GENERATED file is skipped (it has no path until make() runs), and a file inside a dependency importing that dependency's OWN internal module name is still a miss — that would mean running its build.zig. TRAP: the table is folded out of root_mod.import_table, so `addOptions` had to move BELOW every `addImport` call. Attached where it was, the table is empty. TOPBAR GAINS `Help`, WHICH IS WHY `SPC ?` LOOKED BROKEN. A bare `pardes` boots straight into tty mode (main.zig: `args.len == 1`), where every printable key belongs to the shell — so SPC never reaches the leader, and `SPC ?`, the one thing that would tell you the leader exists, is exactly the thing you cannot press. Ctrl-b first and it all works; nothing was broken. But "the help is unreachable until you already know the escape hatch" is a bad answer, and there was no mouse route either: Help was the one builtin missing from the bar. Row 0 is not a pane, so a middle-click there is dispatched before any pane's mode is consulted — the word works in tty mode, which is the only reason it earns the width. APPENDED, not inserted, so every existing topbar word keeps its column and no golden's click coordinates move. test/snapshots/ttyhelp.snap pins it from a bare boot: click Help, get the list, shell still TTY at its prompt, then Ctrl-b + SPC ? for the keyboard route. All 58 goldens carry row 0, so all 58 moved. Verified mechanically that the only changes are the row-0 text and the row-0 style run (0-47 -> 0-52), plus: dump/restore record the topbar inside their .zon, and tagnav's `$`+Enter now executes `Help` rather than `Grep` because the bar's last word changed — still exactly what that step's comment claims it tests. THE DEPENDENCY FIX HAS A CEILING, NOW STATED. The module map is consulted from OUR goto handler, not from inside ZLS, so the analyser still cannot type the `vaxis` const: `gd` on `@import("vaxis")` opens the file, `gd` on `vaxis.init` finds nothing. That is now spelled out at the top of lsp_zls.zig and on moduleRoot rather than left implied, and `SPC l w` detects the case by name — if the left side of a failed field access is a known dependency it says so, instead of the generic "could not resolve". Lifting it means giving ZLS a real BuildConfig, either by letting it run the build runner (a subprocess, and with no cross-query cache that is once per keypress) or by synthesizing one into BuildFile.impl. Both are real work and neither is smuggled in. Also fixed while there: the field-access miss was only explained when ZLS returned null, but it returns an EMPTY SLICE when it typed the left side and found no such member. Both are "gd did nothing" from the outside; both are explained now.
* lspbench: probe diagnostics and format against a BROKEN fixtureGabriel Schneider2026-08-01
| | | | Plus docs/lsp-evaluation.md: the three backends measured head to head.
* LSP seam: async execution model, helix keymap, evaluation harnessGabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The base every language backend plugs into. Three parts: ASYNC. The core had no request/response shape - every effect was fire-and-forget or instantaneous. A language query is the first thing that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp. tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread + the mutex queue it already had for ptys; web no-ops it. The worker never touches the core (path/source/arg are snapshotted into an LspJob), one query in flight identified by a monotonic id so a second press makes the first answer stale, and no rows is a legal answer. KEYMAP. Helix's, verified against its default.rs rather than recalled. gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc (closing a pane is a window op, and c is helix's own close), Dump/Restore s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map. RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what look.zig resolves and n/N step, so one row from a goto jumps and several open a buffer - helix's multi-result picker needed no picker code. Backends supply exactly one function (lsp.query) plus a supports set and a name; the base has none on purpose. zig build lspbench scores them on the same corpus: feature matrix (trusting results, not the supports flag - a claimed-but-empty kind is reported as a false claim), cold and warm latency, peak RSS. Two snapshot scripts moved. leader.snap encoded the old key paths. chordcut.snap's last two steps clicked column 5, which lands on a FILE pane, so 'key c-b' toggled nothing and the typed text was being read as normal-mode keys - the golden recorded no TTY pane and no cat -v output anywhere. Pointing them at an actual shell makes both steps assert what their comments claim, and the tty paste chord is now covered for the first time.
* Builtins (Find, Grep) qol improvements and ui fixesGabriel Schneider2026-08-01
|
* k off the topmost tagline focuses the TOPBAR: row 0 is a tagline tooGabriel Schneider2026-08-01
|
* tag columns are the WHOLE rendered tag: h/j/k/l walk taglines, the path ↵Gabriel Schneider2026-08-01
| | | | selects but never edits
* an output pane is not a document: +Search/+Help split below the pane that ↵Gabriel Schneider2026-08-01
| | | | | | | | | | | | | | | | | | | | asked, never into a column of their own placeDoc classified any file pane as a doc, so a +Search opened from a lone shell took layoutInsertColumn(0) - a whole new leftmost column, shoving every existing column sideways - and an open results list was then eligible as the split parent for a real file, stacking documents under the list. An output buffer has no file behind it and no Save; it is the result list belonging to the pane that asked for it. Now it is that on both sides of the rule: it splits below from_id whatever kind of pane that is (a source that died falls back to stackDocLeft, the same last resort a full column bar uses), and it is skipped when a real doc hunts for its split parent, so a file opened from a search row joins the docs instead of the results. Real docs are untouched: fsearch/rsearch/look-file/tutor/lookpanes all pass unchanged. psearch is the proof - its results snap was two columns (the shell pushed to x=70, its gutter run at 70-71) and is now one 140-wide column with the +Search stacked below at the same x origin.
* look resolves against every open pane's directory: clicked pane first, then ↵Gabriel Schneider2026-08-01
| | | | most-recently-focused, search only when all fail
* helix theme, now the default: near-black page, chrome one grey-ramp step off itGabriel Schneider2026-08-01
| | | | | | | | Colors lifted from the user's own helix theme (~/.config/helix/themes/pardes.toml, pinned against a real hx render through a pty). Theme gains box/box_dim so a restrained theme can turn the accent down; the hardcoded gray.* overlay greys were the dark theme's own fields spelled twice and are gone. The old default is still one NextColor away.