# pardes ↔ helix keybinding plan Living tracking doc for the helix-parity effort. Every key / table row in `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, 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) - **FULL HELIX MOTION MODEL (phase 5 decision — overrides phase 2's "keep point motions").** Motions select the range they traverse with helix's landing conventions: after `w` on "alpha beta" the anchor is (0,0) and the block cursor sits ON the space at col 5 (not vim's col 6); a following `d` deletes "alpha ". Which motions select a range and which collapse to a point follows helix exactly (word/find/till/ paragraph-class motions select; h/j/k/l-class char and line moves collapse) — the checked-in helix goldens are the ground truth, verified case-by-case by `zig build hxdiff`. Motion-created ranges live in `pane.vsel` marked IMPLICIT (`vsel.explicit = false`); `v` extend mode, `x`/`X` and `n`/`N` (the look-ring walk, `lookWalk`) set EXPLICIT ones. `d`/`c`/`y`/`~`/… act on the active selection either way — that is the point of the model. The pardes-specific chords (normal-mode Enter look / Tab execute) act on EXPLICIT selections only and fall back to the word under the cursor when the selection is implicit motion residue. Mode is reported "select" ONLY while `v` extend mode is on (helix keeps mode normal for x/%/mi-created selections). - **Stored columns are UTF-8 byte offsets** (`cur_col`), but every modal 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 mutating half is file-pane only or run-only). - **Soft wrap is a toggle** (`Wrap`, `SPC t w`, on by default), and the visual/textual pair is assigned the other way round from helix: `j`/`k` are TEXTUAL file lines, `gj`/`gk` follow the automatic breaks. A file pane's `j` is expected to move one line of the FILE; helix makes `j` the visual one and `gj` the textual one. With the toggle off a line is one visual row and the two pairs are the same motion. - **Undo is snapshot-per-edit-op / per-insert-session**, not a transaction log. Anything requiring replayable edits (`.`, macros) needs new machinery. ## A. Implemented today Parity column (phase 5): **helix-verified** = matched field-by-field (text/mode/cursor/anchor) against the helix goldens by `zig build hxdiff`; **pardes-specific** = key helix leaves unbound (or a deliberate pardes binding) — cannot mismatch a golden; **waived** = corpus-covered divergence with a reason in `test/hxcases/waivers.jsonl`; **out of corpus** = helix's own key and meaning, but the work leaves the core as an EFFECT the headless differential has no shell to perform — the clipboard commands, the language-backend queries, and the shell pipe. | Key | Behavior (pardes today) | Notes / quirks vs helix | Parity | | --- | --- | --- | --- | | `h` `j` `k` `l`, arrows | char left/right, line up/down — collapse the selection to a point (helix) | `j`/`k` are TEXTUAL lines even under soft wrap (`gj`/`gk` are the visual pair — the reverse of helix's assignment, see the divergence above); sticky col, phantom-line-blocked | helix-verified (unwrapped) | | `w` `b` `e` | select to next word start / prev word start / next word end | full helix model incl. landing conventions (block cursor one before the next word after `w`) and newline/punct/EOF edges | helix-verified | | `W` `B` `E` | long-word (WORD) variants | same | helix-verified | | `Home` / `End` | line start / line end | matches `goto_line_start` / `goto_line_end` | helix-verified | | `0` / `$` / `^` | line start / line end / first non-ws | `0` and `^` are pardes extras — helix leaves those two unbound (it spells them `gh`/`gs`). `$` is NOT unbound in helix: it is `shell_keep_pipe` (`keymap/default.rs`), and taking it for line-end is a deliberate divergence — see the `$` row in C. `0` is a count digit while a count is pending | pardes-specific (`$`: deliberate) | | `G` | bare `G` is a **no-op**; `G` = goto line n | vim-ism removed (phase 5): helix `goto_line` only acts with a count; `ge` is goto-last-line | helix-verified | | `gg` / `gg` | goto first line / line n | | helix-verified | | `ge` | goto last content line | matches `goto_last_line` (ignores the trailing empty line) | helix-verified | | `gh` / `gl` | line start / line end (last char, not the newline) | | helix-verified | | `Ctrl-d` / `Ctrl-u` | half page down/up, cursor follows | matches `page_cursor_half_down/up` | helix-verified | | `Ctrl-f` | full page down | matches `page_down`; file panes only get `Ctrl-b` (see next row) | helix-verified | | `Ctrl-b` | file panes: full page up. Terminal panes: **raw tty mode toggle** (`opts.tty_toggle`, configurable; `Shift-Esc` is a second, fixed binding for the same toggle) | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) | | `PageUp` / `PageDown` | full page | matches helix `page_up`/`page_down` (view scroll + cursor snap to the scrolloff edge) | helix-verified | | `zt` / `zz` / `zb` | scroll current line to top / center / bottom | matches `align_view_top/center/bottom` (helix harness pins scrolloff to pardes' 3) | helix-verified | | `i` `a` | insert at selection start / after selection end | helix semantics: `i` before the selection, `a` selects and appends after it | helix-verified | | `I` `A` | insert at first non-ws / line end | matches `insert_at_line_start` / `insert_at_line_end` | helix-verified | | `o` `O` | open line below / above, copying the current line's indent levels; `o` opens n | matches `open_below`/`open_above` | helix-verified | | `v` | select (extend) mode: motions extend from the fixed anchor; `v` again exits KEEPING the selection | mode "select" is reported only while this is on (helix keeps mode normal for x/%/mi selections) | helix-verified | | `x` / `x` | select current line / extend one (n) line(s) down, char-range through the newline, cursor ON the `\n` | col-0 vim-ism removed (phase 5); matches `extend_line_below`, mode stays normal | helix-verified | | `d` | delete selection (implicit or explicit); bare cursor = the 1-wide selection | always yanks (`Alt-d` noyank in B); terminals: drops typed runs only | helix-verified | | `c` | change (delete + insert mode); linewise selections open a fresh indented line | same shape as `d` | helix-verified | | `y` | yank selection; bare cursor yanks the 1-wide selection (char under cursor) | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). Writes the DEFAULT REGISTER and nothing else — the system clipboard is `SPC y`, which is helix's own split and so moves this row TOWARDS helix, not away: a `d` of one character can no longer clobber what the desktop was holding | helix-verified | | `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, no `Alt-u`/`Alt-U` history walking (skipped) | helix-verified | | `p` (normal) | paste the core's yank register after the selection | helix default-register semantics, and only the register — nothing on this path reads or writes the system clipboard. `SPC p` is the word that does, and on a tty its read is OSC 52, which most terminals refuse: an honest no-op there rather than a paste of the wrong text | helix-verified | | `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, the same chord that leaves a raw tty | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup helix-verified) | | `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified | | `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 (`config.topbar_str`, today `New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill`, plus `Restore ` 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`) | | `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and ` ?` lists one group in `+Help`. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words | | `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus | | a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific | | `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the tag takes the pattern, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific | | `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific | | insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) | | insert: `Enter` | newline; keeps the current full indent levels and adds one 4-space logical tab when the text before the cursor ends in `(`, `[`, `{`, or `)` (including `})`) | plain lines match `insert_newline`; delimiter heuristic is pardes-specific | helix-verified (plain) / pardes-specific (delimiter) | | insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0 | matches `delete_char_backward`; `Ctrl-h` alias in B | helix-verified | | insert: `Up` `Down` `Left` `Right` | move cursor | matches helix's "not recommended" insert arrows | helix-verified | | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | | `=` | `format_selections` — writes a `- old` / `+ new` diff into `+Lsp` | deliberate divergence: the seam returns ROWS, not edits, so this SHOWS the formatting instead of applying it. Not in the corpus, so there is no waiver to name — the query leaves the core as an effect the headless harness has no shell to perform | out of corpus | | `Ctrl-o` / `Ctrl-i` | jumplist back / forward — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | | `\|` | pipe every selection through `/bin/sh -c`: its bytes in on stdin, its stdout replacing them, one undo across all cursors | helix's own key and meaning; the command is typed into the pane's tag after a bare `\|` marker rather than into a popup | out of corpus | ## B. To implement 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'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 ``. `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 | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `` digit prefix | count for motions (h/j/k/l/arrows, w/b/e/W/B/E, gj/gk), `x`, `f/t` family + `Alt-.`, `G`/`gg`, `g|`, `]p`/`[p`, `]Space`/`[Space`, `J`, `>`/`<`, `Ctrl-a`/`Ctrl-x` | `0` stays line-start when NO count is pending, count-digit otherwise. `Pane.count` accumulator, capped at 0xffff (ponytail). Digits are literal char args while a prefix waits (`f4` finds '4'). Any non-prefix key consumes the count; prefix setters carry it into their continuation | helix-verified (phase 5) | | `G`, `gg` | goto line n (1-based, clamped), col 0 | plain `G` is a no-op like helix (vim-ism removed in phase 5; `ge` = last line) | helix-verified (phase 5) | ### Movement | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `f` / `F` | find next / prev char (on it) | NOT confined to current line. `pending` holds the waiting op (`f`/`F`/`t`/`T`); ASCII targets only (byte columns — ponytail); not found = no move | helix-verified (phase 5) | | `t` / `T` | till next / prev char (one short of it) | `modal.findChar` handles all four + counts (nth occurrence, till applied after) | helix-verified (phase 5) | | `Alt-.` | repeat last `f`/`t`/`F`/`T` motion (`Pane.find_op`/`find_ch`), takes a count | decision: repeats ONLY the find family, not `m`/`[`/`]` (helix extends it there; marginal) | helix-verified (phase 5) | | `g|`, `g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) | | `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | ### Changes | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `r` | replace selection/char with ``, 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) | | ``Alt-` `` | selection to uppercase | terminals: run byte only | helix-verified (phase 5) | | `J` | join lines in selection (or cur+next; helix ignores the count) | helix semantics: the newline + next line's leading whitespace collapse to ONE space (first line untrimmed); selection and cursor STAY where they were (vim's cursor-on-the-space removed in phase 5); terminals: no-op | helix-verified (phase 5) | | `>` / `<` | indent / unindent selected lines (count times) | width = `modal.INDENT_W` = **4 spaces** (helix harness pins its no-language indent style to Spaces(4) to match); `<` also takes one leading tab as a level; empty lines never indented (helix); selection kept, positions mapped through the edit (phase 5) | helix-verified (phase 5) | | `Ctrl-a` / `Ctrl-x` | increment / decrement the decimal int under the cursor by count | helix-style: under the cursor only (no vim forward scan), `-` handled, i64 saturating, zero-padding width preserved (phase 5); cursor to the last digit, selection kept; terminals: no-op | helix-verified (phase 5) | | `Alt-d` | delete without yanking | `normalDelete(yank=false)` | helix-verified (phase 5) | | `Alt-c` | change without yanking | **skipped (phase 2)**: the pardes `Alt-c` window op (move pane to fresh column, any mode) wins — it's load-bearing global UX and the parent contract forbids touching it. `Alt-d` + `i` covers the behavior. Corpus-covered and waived (`alt-c-window-op`) | waived (phase 5) | | `P` | paste before | same yank-register path as `p` | helix-verified (phase 5) | | `p`/`P` semantics | yank ending `\n` pastes as whole lines below/above the SELECTION's line span; else inline at the selection's outer edge. The paste (× count) becomes the selection, head on its last char (helix) | terminals keep the run-splice-at-cursor path (before/after collapses). Pinned by yankpaste.snap and the hxdiff yp cases | helix-verified (phase 5) | ### Selection manipulation | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `;` | collapse selection to cursor | drops vsel/msel, cursor stays | helix-verified (phase 5) | | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | ### Multiple cursors helix's `Selection` is a LIST of ranges with a primary index. pardes keeps the PRIMARY where it always was (`cur_row`/`cur_col` + `vsel`) and the other ranges in `Pane.sels`; an ordinary key is REPLAYED once per range (`replaySels`), so every motion and operator above works at every cursor without being rewritten. With one cursor nothing is replayed and nothing changed. The result contract grew `sels` + `primary`, emitted only when there is more than one range. | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `C` / `Alt-C` / `C` | copy the selection to the next / previous line | helix `copy_selection_on_line`, incl. skipping lines too short to hold the column. **Byte columns, not visual ones** (a TAB counts as one) | helix-verified | | `,` / `Alt-,` | keep only the primary / remove the primary | `Alt-,` on a lone cursor is a no-op (helix errors) | helix-verified | | `)` / `(` / `)` | rotate the primary forward / backward (wrapping) | ranges unchanged, only which one is primary | helix-verified | | `Alt-s` | split the selection on newlines | helix `split_on_newline`; the newlines themselves drop out, primary becomes 0 (helix's own TODO) | helix-verified | | `Alt-minus` / `Alt-_` | merge all ranges into one / merge the consecutive ones | helix `merge_selections` / `merge_consecutive_ranges` | helix-verified | | `_` | trim whitespace off both ends of every range | empty and all-whitespace ranges drop; nothing left = collapse + keep primary (helix) | helix-verified | | `o` / `O` | open n lines, one cursor per line | helix `open` with a count; this is why the `o-count` golden gained a `sels` field | helix-verified | | every motion/operator | acts at every cursor | replayed last-range-first, so an edit never disturbs a range still waiting; each pass's result is remembered as a distance from the END of the text, which an earlier edit cannot move | helix-verified (56 msel-* cases) | | `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived | | `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | | `&` | align selections into a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | | `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections | skipped: a separate feature from rotating which range is primary | Anything that reaches outside the buffer — a builtin, a language query — runs once from the primary and drops back to a single cursor rather than firing per range (`multiOnce`). Undo restores the primary from the snapshot and leaves the other cursors where they are (`FileSnap` holds one range). ### Regex selection (`s` / `S`) — the interactive pair Both arm the tag input the same way `/` does — the pattern is typed into the tag tail after a marker, no popup — and both re-run on EVERY keystroke, from the selection the prompt opened on (`Pane.sel_snap`). That is what makes the selection a live preview, what makes typing a pattern one character at a time land where pasting it whole would, and what makes Esc a plain restore. Enter re-runs the final pattern down the same path, so a submit can never disagree with what is on screen. Engine: **mvzr** (`build.zig.zon`), a bytecode VM that compiles a runtime pattern with no allocator. | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `s` Enter | select every match INSIDE each range | helix `select_on_matches`; a match sitting right off a range's end is dropped (what `\b` and empty matches produce there). No match, no pattern, or one that will not compile = the selection is left alone (helix's "nothing selected") | helix-verified | | `S` Enter | split each range on its matches | helix `split_on_matches`; the pieces BETWEEN the matches, including the empty one a leading match produces | helix-verified | | `s`/`S` then Esc | back to the selection the prompt opened on | the empty pattern applies nothing, so cancelling IS the restore — one path, not a second one | helix-verified | | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | assert at the SCAN position, not at a line | helix compiles with `multi_line(true)`, so its `^` is every line start. Waived (`sel-regex-caret`) | waived | | `.` | matches a newline like any other byte | the Rust regex crate excludes `\n` by default; mvzr does not. `[^\n]` is the workaround and agrees in both. Waived (`sel-regex-dot-newline`) | waived | | more than 64 matches | the ones past `MAX_SELS` are dropped | the ceiling the whole selection model has, not this key's | | `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | ### `Ctrl-c` — toggle comments | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `Ctrl-c` | comment or uncomment every line the WHOLE selection touches | helix `toggle_comments`. One decision for the whole set — one uncommented non-blank line and everything gets commented — which is why it is a `wholeKey` and not a per-cursor replay | helix-verified | | … at several cursors | each line once, in order | helix's `min_next_line`: two cursors on one line comment it once | helix-verified | | … with mixed indents | the token goes in at the SHALLOWEST indent in the set | helix's `min`, quirks included: a deeper line is commented mid-whitespace | helix-verified | | … uncommenting | one space after the token goes too, unless some line lacks it | helix's `margin` | helix-verified | | … blank lines | skipped entirely, and they do not vote on commented-or-not | helix | helix-verified | | which token | by file EXTENSION, from `config.comment_tokens` | the same notion of "language" `src/syntax.zig` picks grammars with. No extension (a terminal, an output buffer) or an unlisted one gets `config.comment_token_default` = `#`, which is helix's own `DEFAULT_COMMENT_TOKEN` and therefore what the oracle answers | helix-verified | | block comments (`/* */`) | **skipped**: helix only reaches them when a language declares block tokens and no line tokens; every language in the table has a line comment | ### Match mode (`m` prefix) — plain-text, no tree-sitter | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `mm` | goto matching bracket | dumb text scan with nesting for `()[]{}<>`; ON a bracket only (no TS "nearest pair"); works on terminals via the motion surface | helix-verified (phase 5) | | `mi` / `ma` | select inside / around textobject | pairs `( ) [ ] { } < >` nesting-aware multi-line; quotes `' " `` ` `` ` **line-scoped** (plain-text strings don't span lines); `w`/`W` word run (+trailing ws around, leading if none); `p` blank-line block (+trailing blanks around). Empty inside (`()`) = no-op. Selections work on terminals; `Pane.pending2` holds the i/a/s/r/d sub-key | helix-verified (phase 5) | | `ms` | surround selection (or cursor char) with the `` pair; wrap incl. pair becomes the selection | either bracket names its pair; any other ASCII char wraps with itself; file panes only | helix-verified (phase 5) | | `mr` | replace enclosing `` pair chars with ``'s | `Pane.pending_ch` holds `` while `` pends; file panes only | helix-verified (phase 5) | | `md` | delete the enclosing `` pair chars | file panes only | helix-verified (phase 5) | ### View mode (`z` prefix) additions | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `zj` / `zk` (+ `zdown`/`zup`) | scroll view down / up one line, cursor untouched | `pane.scrollBy(±1)` | helix-verified (phase 5) | | `zc` | center (alias of `zz`) | | helix-verified (phase 5) | | `z Ctrl-d` / `z Ctrl-u` | half page with cursor | aliases of the bare handlers | helix-verified (phase 5) | | `z Ctrl-f` / `z Ctrl-b` / `z PageUp` / `z PageDown` | full page | aliases | helix-verified (phase 5) | ### Unimpaired (plain-text subset) | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `]p` / `[p` | next / prev paragraph (+ count) | helix `goto_next/prev_paragraph`: selects to the boundary (anchor at the origin), blank-line-delimited blocks, count iterates | helix-verified (phase 5) | | `]Space` / `[Space` | add `` blank lines below / above, cursor staying on its text line | file panes only (shell output immutable) | helix-verified (phase 5) | ### Insert mode All the mutating insert keys follow the pane split: file panes edit content (`modal.deleteSpan` for the kills), terminal panes edit ONLY the typed run at the cursor (word = space-delimited within the run). Ctrl-h/j/d are normalized to Backspace/Enter/Delete and re-dispatched at the top of `handleInsert`. The text-insert path now requires no ctrl/alt so modifier combos can't leak their text. | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `Ctrl-w` / `Alt-Backspace` | delete word backward (ws then word-class run; at col 0 = the ordinary backspace join) | resolved per proposal: focus prefix restricted to normal/tty; insert owns `Ctrl-w` | helix-verified (phase 5) | | `Alt-d` / `Alt-Delete` | delete word forward (word run + trailing ws; at EOL eats the newline) | | helix-verified (phase 5) | | `Ctrl-u` | kill to line start | | helix-verified (phase 5) | | `Ctrl-k` | kill to line end | | helix-verified (phase 5) | | `Ctrl-h` | delete prev char (Backspace alias) | | helix-verified (phase 5) | | `Ctrl-d` / `Delete` | delete next char; at line end joins the next line up | `Key.delete` (0xF0009) added + `mapKey` in tty.zig/gui.zig (vaxis + SDLK) + `forwardKey` sends `ESC[3~` in tty mode | helix-verified (phase 5) | | `Ctrl-j` | insert newline (Enter alias) | | helix-verified (phase 5) | | `Home` / `End` | line start / line end past-the-last-char (`goto_line_end_newline`) | file panes (terminal insert cursor rides its run, as before) | helix-verified (phase 5) | | `PageUp` / `PageDown` | cursor page up / down, col kept (clamped to line) | file panes | helix-verified (phase 5) | | `Tab` | indent to the next 4-column stop with SPACES (`insertTab`, `pardes.zig`: `pad = INDENT_W - col % INDENT_W`) — no `\t` byte ever reaches the file | helix's Spaces indent style; helix smart-tab skipped. After a `.` in a file the backend speaks, Tab instead asks for completion and only indents if the answer is empty (section A) | helix-verified (phase 5) | ## C. Skipped | Key(s) | Helix behavior | Reason | | --- | --- | --- | | `?`, `*`, `Alt-*` | rsearch / selection-as-pattern | search — pardes has its own `/` (plain substring into an output buffer, kept as-is) and an `n`/`N` that SELECTS the next look-able text instead of walking match hits; helix regex search machinery not wanted | | `Space` mode: `f F e . b j g G ' w c C Alt-c / ?` | pickers, global search, palette | pickers and the command palette do not exist here — but the KEY is taken: pardes' own leader runs the acme builtins (section A). Two halves of helix's space mode DO exist and are in section A: the clipboard menu (`y Y p P R`, helix's letters on helix's leader) and the LSP menu, moved one prefix deeper to `SPC l k/r/a/h/s/S/d/D` because `d`, `k`, `s` and `h` were already pardes' most-pressed keys | | Popup `Ctrl-u`/`Ctrl-d`, Completion menu, Signature help tables | LSP popups | LSP | | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | `gw` | word-label jump | label-jump overlay machinery, not core editing | | `g.` | goto last modification | jumplist/history position tracking | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-n`/`Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | syntax-node selection | tree-sitter | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `"` ``, insert `Ctrl-r` | register select / insert | registers — one yank register and no way to name a second; the system clipboard is not spelled as a register here either, it is the five `SPC` commands in A | | `Q` / `q` | record / replay macro | macros — needs replayable input log | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | | `&`, `Alt-(` / `Alt-)` | align selections, rotate selection CONTENTS | see the multiple-cursors table for why | | `Alt-J` | join + select the inserted space | marginal over `J` | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | | insert `Ctrl-s` | commit undo checkpoint | undo is per-insert-session snapshots; no sub-session checkpoints | | `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | | `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | | `zm` (view) | align middle horizontally | marginal even with hscroll | | `.` | repeat last insert | **deferred by decision**: needs recording the insert session's keystrokes and a replay path — a new subsystem; undo is whole-buffer snapshots with no edit log to piggyback on. Revisit after phases 2–5 if the log exists by then for another reason | | Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes' `n`/`N` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) pardes is diffed key-for-key against real helix (checkout `~/05-genizah/helix`, branch `pardes-harness`) over a shared JSON-Lines case corpus. Both sides speak the same contract: a case is `{"name","pane","text","keys"}` (helix key notation; `pane` 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. 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/`): - `cases.jsonl` — the corpus: 481 cases (360 file + 121 tty). Every helix-equivalent row of sections A and B has at least a typical and an edge case; pure navigation/selection bindings get a `pane:"tty"` twin (same text/keys — helix on that text IS the oracle for tty navigation parity). No tty twins for content-mutating ops (shell output is immutable) or doc-marked terminal no-ops. tty case texts keep motions inside the content (the tty motion surface trims trailing blank rows, so ge/G-to-last-line style assertions stay off tty). - `goldens.jsonl` — checked-in helix results, regenerated by `regen.sh` (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. - `waivers.jsonl` — named exemptions, each with a reason. Six live ones: `wiX-edit-drops-sel` and `msel-append` (helix maps selections through insert-mode edits, pardes does not), `alt-c-window-op` (the pardes window op deliberately shadows helix change-noyank), `msel-yank-paste` (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). - `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: a full headless `Application` with LSP/tree-sitter/auto-pairs/word- completion off, scrolloff pinned to pardes' 3, no-language indent style pinned to Spaces(4), smart-tab off, and the buffer-setup transaction committed as its own undo revision. Build: `cargo build --release -p helix-term --features helix-term/integration --bin hx-harness`.