diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 18:58:37 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch) | |
| tree | 6629cc215d6953090f6b29a7414b28cb9990e105 /docs/helix-keys.md | |
| parent | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff) | |
| download | pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip | |
An edited row keeps its colours, four copies of forkShell become one, and 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.
Diffstat (limited to 'docs/helix-keys.md')
| -rw-r--r-- | docs/helix-keys.md | 104 |
1 files changed, 78 insertions, 26 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index b307ae30..81bc0248 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -1,21 +1,34 @@ # pardes ↔ helix keybinding plan Living tracking doc for the helix-parity effort. Every key / table row in -`helix/book/src/keymap.md` (checkout: `~/05-genizah/helix`, HEAD 278b24389) -appears in exactly one of the three sections below. As of phase 5 every -helix-equivalent row in A and B is differentially verified against real -helix (see "Differential testing" at the bottom); the two waivers are -listed in their rows. +`helix/book/src/keymap.md` appears in exactly one of the three sections +below. The keymap was read at UPSTREAM commit 278b24389 of the genizah +checkout (`~/05-genizah/helix`); that checkout now sits on the local +`pardes-harness` branch, whose three commits add only the harness, so +`book/src/keymap.md` is byte-identical at its tip (694e7dfd). As of phase 5 +every helix-equivalent row in A and B is differentially verified against real +helix (see "Differential testing" at the bottom). Five of the six waivers are +named in the row they belong to; the sixth, `wiX-edit-drops-sel`, belongs to no +single key — it is the anchor-only divergence every insert-mode edit shares — +and is described in the Files list at the bottom instead. -Code map (line numbers approximate): `src/pardes.zig` — `Key` ~296, -`handleKey` ~1171 (intercept order is load-bearing, see comment there), -`setPaneRange` ~1566 (helix range → pane state), `handleNormal` ~1736, -`enterInsert` ~2171, `handleInsert` ~2278, `normalDelete` ~2600, -`normalYank` ~2686, `normalPaste` ~2727, `normalChange` ~2799, undo ~3386. -Pure text math: `src/modal.zig` (the `hx*` family is the helix-semantics -layer: gap offsets + ranges over the flat text). Differential harness: -`test/hxdiff.zig` + `test/hxcases/{cases,goldens,waivers}.jsonl` + -`test/hxcases/regen.sh` — see "Differential testing" at the bottom. +Code map, by SYMBOL — line numbers rot, names do not. Body-normal key +RECOGNITION is `src/normal_input.zig`: a pure state machine over `Role` (one +per bound command, matched against `src/config.zig`'s chord lists by +`Pardes.normalInput`), a `Prefix`, a `MatchSub` and a count, emitting a +semantic `Action` that both the text and PDF adapters consume. It knows +nothing about panes or text. `src/pardes.zig` then EXECUTES: `Key`, +`handleKey` (intercept order is load-bearing, see the comment there), +`handleNormal` (marshals `Pane`'s compact fields in and out of +`normal_input.State` through `paneNormalState` / `putPaneNormalState`), +`executeNormalAction`, `setPaneRange` (helix range → pane state), +`enterInsert`, `handleInsert` / `insertKey`, `insertTab`, `normalDelete`, +`normalYank`, `normalPaste`, `normalChange`, `replaySels`, `multiOnce`. Undo +and redo are per pane kind, in `file_pane` and `term_pane`. Pure text math: +`src/modal.zig` (the `hx*` family is the helix-semantics layer: gap offsets + +ranges over the flat text). Differential harness: `test/hxdiff.zig` + +`test/hxcases/*.jsonl` + `test/hxcases/regen.sh` — see "Differential testing" +at the bottom. ## Global semantic divergences (read first) @@ -41,6 +54,15 @@ layer: gap offsets + ranges over the flat text). Differential harness: cursor/range endpoint is snapped to an extended-grapheme boundary. Rendering, mouse input and vertical motion translate those offsets through terminal-cell widths, so combining sequences and wide glyphs remain single cursor cells. + Three functions in `modal.zig` do the snapping — `graphemeStart` (repair an + offset back onto a boundary), `nextGrapheme`, `prevGrapheme` — each with an + arithmetic ASCII fast path over the UAX #29 segmenter. All three carry the + same exclusion by hand, because GB3 is the one UAX #29 rule that joins two + ASCII scalars: a CR takes a following LF into the same cluster. `nextGrapheme` + and `prevGrapheme` did not spell it out and so disagreed with `graphemeStart` + by exactly one byte on a CRLF file — a head could step to the offset between + CR and LF and be repaired straight back. Fixed; the test "GB3 keeps CR-LF one + cluster for every grapheme step" is what holds the three in agreement. - **Terminal panes: shell output is immutable.** Edit ops only ever drop/alter typed insertion runs. All "To implement" edit ops follow the same rule (motion/selection parts work on the full motion surface; the @@ -97,7 +119,7 @@ language-backend queries, and the shell pipe. | `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific | | `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific | | `:` (normal, body) | focuses the pane's OWN tag as a one-line editor in **normal** mode, parked at the first EDITABLE column: motions (`w` `b` `e` `W` `B` `E`, `0` `$` `^`, arrows, `Home`/`End`) walk the whole rendered tag, `y` yanks the selection, `Enter`/`Tab` look/execute it (else the file-ish word under the cursor), `i`/`a`/`I`/`A` enter insert, `Esc` hands the body back. `h`/`j`/`k`/`l` are NOT motion here — a tagline is a place in the LAYOUT, so they run the same `Left`/`Down`/`Up`/`Right` builtins and land on the neighbouring pane's TAGLINE, still in normal mode (nothing that way = stay put, EXCEPT `k` off the topmost tagline — see the next row); the arrows keep the in-tag motion | helix `:` is command mode (section C); pardes' commands are acme words that live in the tag. `tag_col`/`tag_anchor` are columns of the RENDERED tag (prefix ++ tail) — one coordinate space, so the live mode+path prefix is selectable, yankable and executable, while every edit op (typing, `Backspace`, `i`/`a`/`I`/`A`) measures from the first editable column and is inert inside it | pardes-specific | -| `k` (tag normal, topmost tagline) | focuses the TOPBAR — row 0, the global tagline (`New Newcol Find Grep Help Tutor Dump NextColor Debug Kill`, plus `Restore <path>` once a dump exists). It is its own one-line normal mode: `h`/`l` and the arrows by grapheme (row 0 has no window left or right to walk to), `w`/`b`/`e`/`W`/`B`/`E` and `0`/`$`/`^` by word, `Enter`/`Tab` runs the word under the cursor through the same dispatch a MIDDLE click on it uses, `j` drops back onto the topmost pane's tagline, `Esc` leaves. No insert mode and no selection — the bar is chrome with no tail to own | pardes-specific. The topbar is not a pane, so `focusDir` can never reach it: this is a fallback on the `.Up` branch of the tagline hop, from a TAGLINE only (a body's `SPC w k`/`Ctrl-w k` keep their pane-to-pane meaning). Its whole state is one global `topbar_col: ?u16` (row 0 has no pane to hang it on), cleared by any mouse press and BEFORE the chord dispatch, because `Kill` up here frees the session the way `Del` frees a pane. The motion vocabulary is literally the tag's — both call `lineMotion` | pardes-specific | +| `k` (tag normal, topmost tagline) | focuses the TOPBAR — row 0, the global tagline (`config.topbar_str`, today `New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill`, plus `Restore <path>` once a dump exists). It is its own one-line normal mode: `h`/`l` and the arrows by grapheme (row 0 has no window left or right to walk to), `w`/`b`/`e`/`W`/`B`/`E` and `0`/`$`/`^` by word, `Enter`/`Tab` runs the word under the cursor through the same dispatch a MIDDLE click on it uses, `j` drops back onto the topmost pane's tagline, `Esc` leaves. No insert mode and no selection — the bar is chrome with no tail to own | pardes-specific. The topbar is not a pane, so `focusDir` can never reach it: this is a fallback on the `.Up` branch of the tagline hop, from a TAGLINE only (a body's `SPC w k`/`Ctrl-w k` keep their pane-to-pane meaning). Its whole state is one global `topbar_col: ?u16` (row 0 has no pane to hang it on), cleared by any mouse press and BEFORE the chord dispatch, because `Kill` up here frees the session the way `Del` frees a pane. The motion vocabulary is literally the tag's — both call `lineMotion` | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — normal/tty modes only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; kept alongside the leader because a pane in raw **tty** mode never sees `SPC` (the shell owns it), so this is the only keyboard way out of one. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode (`:`) it moves focus to the neighbour's BODY, while the bare letters `h/j/k/l` there move to its TAGLINE (next row) | pardes-specific | | `Alt-n` | new terminal below (any mode) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | | `Alt-c` | move active terminal to a fresh column (any mode) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | @@ -122,13 +144,19 @@ Status: `todo` → set to `done (phase N)` as rows land. All rows verified against keymap.md. Edit ops on terminal panes obey the immutable-output rule (typed runs only) — same as `d`/`c` today. -Phase 2 state added to `Pane`: `count` (accumulator, capped 0xffff), -`pending2` (m-mode sub-key), `pending_ch` (mr's `<from>`), `find_op`/`find_ch` -(Alt-. repeat). `pending` also holds `m` `[` `]` and the char-arg ops -`f F t T r`. New pure text math in modal.zig (`findChar`, `matchBracket`, -`paragraphFwd/Bwd`, textobject/surround ranges, `replaceRange/Chars`, -`changeCase`, `joinLine`, `indentLines`, `adjustNumber`, `deleteSpan`, -`advanceBy`) with inline tests; `zig build unit-test` runs them. +Phase 2's recognition state is now `normal_input.State`: a count (accumulator, +capped 0xffff), a `Prefix` (`g` `z` `m` `f` `F` `t` `T` `r` `]` `[`), a +`MatchSub` (m-mode's `i`/`a`/`s`/`r`/`d`) and one `held_char` for `mr`'s +`<from>`. `Pane` keeps the same four fields it always did — `count`, +`pending`, `pending2`, `pending_ch` — but purely as compact per-pane storage, +marshalled in and out by `paneNormalState` / `putPaneNormalState`; the +comments on them say so. `find_op`/`find_ch` (the `Alt-.` repeat target) stay +on `Pane`, because the parser emits `repeat_find` without remembering what was +found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, +`paragraphFwd`/`paragraphBwd`/`paragraphRange`, textobject/surround ranges, +`replaceRange`/`replaceChars`, `changeCase`, `joinLine`, `indentLines`, +`adjustNumber`, `deleteSpan`, `advanceBy` — each with inline tests that +`zig build unit-test` runs. ### Counts @@ -154,7 +182,7 @@ Phase 2 state added to `Pane`: `count` (accumulator, capped 0xffff), | Key | Behavior | Notes | Status | | --- | --- | --- | --- | -| `r<ch>` | replace selection/char with `<ch>`, newlines kept | terminals: replaces the typed-run byte under the cursor if any, else no-op (`runByteAt`) | helix-verified (phase 5) | +| `r<ch>` | replace selection/char with `<ch>`, newlines kept | terminals: replaces the typed-run byte under the cursor if any, else no-op — one code path for both kinds, since `normalReplaceChar` goes through `Pardes.editText`/`setEditText`, which route to a file's content or to `term_pane`'s typed-run overlay | helix-verified (phase 5) | | `R` | replace selection (or cursor char) with the yank register; pasted text becomes the selection, head on its last char | uses the INTERNAL yank (`p.yank`), no clipboard round trip — `SPC R` is the system-clipboard twin; empty register = no-op; terminals: no-op | helix-verified (phase 5) | | `~` | switch case of selection/char, selection kept | terminals: run byte only | helix-verified (phase 5) | | `` ` `` | selection to lowercase | terminals: run byte only | helix-verified (phase 5) | @@ -331,7 +359,12 @@ case corpus. Both sides speak the same contract: a case is `"file"`/`"tty"`, ignored by helix), a result is the full final buffer text, the mode (`normal`/`insert`/`select` — select only for `v` extend mode), and the block-cursor positions of the primary selection's head -(`cursor`) and other end (`anchor`), 0-based row + byte col. +(`cursor`) and other end (`anchor`), 0-based row + byte col. With more than +one selection the result line grows two more fields — `sels`, every range in +document order measured the same way, and `primary`, the index of the one +`cursor`/`anchor` describe. Both are omitted at one selection, which is why +every golden written before multiple cursors existed is still byte-for-byte +valid. Files (all in `test/hxcases/`): @@ -354,14 +387,33 @@ Files (all in `test/hxcases/`): (one yank register, not one value per range), and `sel-regex-caret` / `sel-regex-dot-newline` (mvzr is not the Rust regex crate: no multi-line `^`/`$`, and `.` matches a newline). -- `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the core - headlessly at 80x24 (22 body rows, matching helix's 22 text rows). +- `parity.jsonl` — 80 further cases, used only by the parity gate below. +- `parity-waivers.jsonl` — 13 named exemptions for the parity gate, in three + classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the + tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll + below the vt's live grid bottom, so the cursor snaps into a different + scrolloff band — no text differs), and four case texts a pty cannot hold + verbatim (a literal TAB the emulator expands, a file with no trailing + newline, an all-whitespace last row the dump trims). +- `smoke.jsonl` — 20 cases referenced by nothing in the tree: no build step, + no script. Either wire it up or delete it. + +The pardes half is `test/hxdiff.zig`, which builds `pardes-hxdiff` and drives +the core headlessly at 80x24 (22 body rows, matching helix's 22 text rows). Run it: zig build hxdiff # cases + goldens + waivers, field-by-field; # unwaivered mismatch = per-case report + exit 1 zig build hxdiff -- test/hxcases/cases.jsonl # results to stdout, no diff + zig build hxparity # the SAME binary in --parity mode: cases.jsonl + # + parity.jsonl, each case run twice over the + # same text and keys (once in a file pane, once + # in a pty pane), both result lines must match. + # No goldens — the file pane IS the oracle, so + # "editing a shell pane behaves like editing a + # file" cannot drift. The case's own "pane" field + # is ignored; exemptions in parity-waivers.jsonl sh test/hxcases/regen.sh # regenerate goldens The helix half (`hx-harness`) lives on the `pardes-harness` branch: |
