summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md36
-rw-r--r--docs/selections.md22
-rw-r--r--src/Output.zig2
-rw-r--r--src/Terminal.zig8
-rw-r--r--src/Text.zig4
-rw-r--r--src/edit.zig127
-rw-r--r--src/look.zig2
-rw-r--r--src/mouse.zig4
-rw-r--r--src/normal.zig6
-rw-r--r--src/tagline.zig4
-rw-r--r--test/hxcases/cases.jsonl15
-rw-r--r--test/hxcases/goldens.jsonl15
-rw-r--r--test/hxcases/waivers.jsonl2
-rw-r--r--test/mode.zig6
-rw-r--r--test/panes.zig4
-rw-r--r--transactions.txt15
16 files changed, 194 insertions, 78 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md
index 90efc463..35f62765 100644
--- a/docs/helix-keys.md
+++ b/docs/helix-keys.md
@@ -7,10 +7,17 @@ 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.
+helix (see "Differential testing" at the bottom). Every waiver is named in the
+row it belongs to.
+
+**The reference helix is fixed at one commit:** the `hx-harness` built from
+`694e7dfdd` on the genizah checkout's `pardes-harness` branch, which is upstream
+`278b24389` (2026-06-29, `25.07-905`) plus the three harness commits. "helix"
+in this document means that build, not the 25.07.1 release, which differs on
+counted `Alt-(`/`Alt-)` and on `&`. Upstream master was 82 commits further on
+2026-09-28 (`079a789e`); of those only `416a0e09` (continuing a comment in an
+injected `comment` layer) touches the editing code, and it needs tree-sitter,
+which the harness runs without.
Code map, by SYMBOL — line numbers rot, names do not. Body-normal key
RECOGNITION is `modal.Normal` in `src/modal.zig`: a state machine over `Role` (one
@@ -227,7 +234,7 @@ grew `sels` + `primary`, emitted only when there is more than one range.
| `y` with several ranges | one value per range, pasted back value i at range i by `p`/`P`/`R` and insert `Ctrl-r`; the acme cut/paste chords and a paste into a terminal take the values joined by newlines | `Registers.zig` | helix-verified |
| `"<reg>` | the next command's register: any character names one of its own. Computed ones: `_` swallows writes and reads nothing (`"_d`), `#` is each range's number from 1, `.` each range's text, `%` the file's name, `/` the last `s`/`S` pattern; `+`/`*` the system clipboard. A count typed before `"` stays the command's | helix `select_register` | helix-verified |
| insert `Ctrl-r <reg>` | type this range's value of the register (`Ctrl-r #` numbers the cursors). Esc after `Ctrl-r` only cancels it. `Ctrl-r +` reads what pardes last put on the clipboard, not the desktop's | helix `insert_register` | helix-verified |
-| `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 |
+| insert mode with a selection | every range is carried through each edit as helix maps it (`Range::map`): typing at the head of an `i` range slides it, typing at the end of an `a` range stretches it, Enter slides or stretches it by what the cursor moved, and an arrow key collapses it. Esc after `a` pulls each range's end back one character (helix `restore_cursor`) | `edit.insertKey`, `Text.restore_cursor` | helix-verified |
| `&` | align selections into columns: the k-th range of each line is column k, and spaces go in before each range until its head reaches the column's widest head, in display cells (`File.rawDisplayCol`) | helix `align_selections` as of the harness's helix (25.07.1 grouped columns differently). A range over several lines refuses. A TAB counts as `tab_width` cells, not up to the next stop | helix-verified |
| `Alt-)` / `Alt-(` / `<n>Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified |
@@ -389,11 +396,11 @@ Files (all in `test/hxcases/`):
(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. Three live ones:
- `wiX-edit-drops-sel` and `msel-append` (helix maps selections through
- insert-mode edits, pardes does not) and `alt-c-window-op` (the pardes
- window op deliberately shadows helix change-noyank). `msel-yank-paste`
- went with registers of one value per range; `sel-regex-caret` and
+- `waivers.jsonl` — named exemptions, each with a reason. One live one:
+ `alt-c-window-op` (the pardes window op deliberately shadows helix
+ change-noyank). `wiX-edit-drops-sel` and `msel-append` went when insert
+ mode began carrying every range through its edits, `msel-yank-paste` with
+ registers of one value per range, `sel-regex-caret` and
`sel-regex-dot-newline` when `s`/`S` began searching line by line.
- `parity.jsonl` — 80 further cases, used only by the parity gate below.
- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three
@@ -412,10 +419,11 @@ Files (all in `test/hxcases/`):
tested when an earlier one misses. The goldens are
`hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under
the site's own conditions (a file of the example's language, auto-pairs
- on). Under the harness pins two do not: `csv_to_sql` needs auto-pairs to
- close its `VALUES (`, and `invert_dictionary_2` needs 25.07's
- `<count>Alt-(` (rotate within groups of count) where the harness's newer
- helix rotates by count.
+ on). Under the harness two do not, and their goldens are the harness's
+ anyway, which pardes follows: `csv_to_sql` needs auto-pairs (off in the
+ harness and absent in pardes) to close its `VALUES (`, and
+ `invert_dictionary_2` needs 25.07.1's `<count>Alt-(` (rotate within groups
+ of count) where the reference helix rotates by count.
- `smoke.jsonl` — 20 cases referenced by nothing in the tree: no build step,
no script. Either wire it up or delete it.
diff --git a/docs/selections.md b/docs/selections.md
index 0f000e2c..c08ccb14 100644
--- a/docs/selections.md
+++ b/docs/selections.md
@@ -3,6 +3,8 @@
How normal mode holds and changes its selections, and where that differs from
helix's `Selection` (a list of ranges over gap offsets, with a primary index).
Key-by-key behaviour is in docs/helix-keys.md; this is the model underneath.
+"helix" here is the reference build docs/helix-keys.md names: `hx-harness`
+at `694e7dfdd`, upstream `278b24389`.
## What a selection is
@@ -25,8 +27,9 @@ plus up to 63 others.
`s`, `n`/`N`, ...) rather than left behind by a motion. Only pardes reads
it: the look (Enter) and execute (Tab) chords act on explicit selections
and on the word under the cursor otherwise.
-- `select` is `v` extend mode. `append_at` remembers where an `a` began, so
- Esc can give back the range it appended over.
+- `select` is `v` extend mode. `restore_cursor` says the insert session
+ began with `a`, so Esc gives each range back the character it was
+ stretched by.
`Text.setRanges` is the one writer of a whole selection: it sorts by start,
merges ranges that overlap or share a start (helix `normalize`), follows the
@@ -67,6 +70,17 @@ A parsed action has a scope (`modal.Normal.Action.scope`).
look) runs once from the primary and drops back to one cursor
(`normal.multiOnce`).
+## Insert mode
+
+Each range lives on through insert mode. Every insert-mode edit happens at
+the cursor, so `edit.insertKey` runs it on the bare cursor and then carries
+the range through what it did to the text, with helix's `Range::map` rules:
+at an insertion, a backward range's head and an empty range move past it
+and a forward range's head stays before it, so typing slides an `i` range
+and stretches an `a` range. Enter slides or stretches the range by what the
+cursor moved; an arrow key collapses it. The other ranges get the same by
+the replay.
+
## Undo, registers, repeat
- One keystroke is one undo step however many ranges it edited: only the
@@ -86,10 +100,6 @@ A parsed action has a scope (`modal.Normal.Action.scope`).
## Where it differs from helix
-- Insert mode: helix maps every range through each keystroke's edit, so a
- range an `i`/`a` started from stretches over what is typed and keeps its
- anchor. Here only the cursor cells move, and after Esc only the primary's
- `append_at` span comes back (waivers `wiX-edit-drops-sel`, `msel-append`).
- Direction of a one-character range: both of its cells are the same cell,
so it has none. helix can flip one (`Alt-;`) and the commands that read
the head (`&`, the next extend) see the difference.
diff --git a/src/Output.zig b/src/Output.zig
index 6e1765a7..201b5491 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -749,7 +749,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void {
pane.body.select = false;
pane.sel = @splat(.{});
pane.body.sticky_col = -1;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.normal.clear();
pane.look_at = null;
if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) {
diff --git a/src/Terminal.zig b/src/Terminal.zig
index e204b44c..e3378007 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -717,7 +717,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void {
if (comptime !enabled) return;
if (pane.terminal == null) return;
const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or
- pane.body.msel.active or pane.body.nsel > 0 or pane.body.append_at != null or pane.look_at != null or
+ pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or
pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0;
// Sticky bottom, as every other terminal does it: output drags the
// viewport down only when the viewport was already on the last row, so
@@ -843,10 +843,6 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void {
extra[nextra + 1] = &selection.arow;
nextra += 2;
}
- if (pane.body.append_at) |*at| {
- extra[nextra] = &at.row;
- nextra += 1;
- }
if (pane.look_at) |*at| {
extra[nextra] = &at.row;
nextra += 1;
@@ -1124,7 +1120,7 @@ pub fn enterTty(p: *Pardes, id: usize) void {
// keeps its modal endpoint for the next return to editor mode.
pane.body.cur_pinned = selected;
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
pane.body.normal.clear();
}
diff --git a/src/Text.zig b/src/Text.zig
index 7ad243a1..764b8a6a 100644
--- a/src/Text.zig
+++ b/src/Text.zig
@@ -65,7 +65,9 @@ select: bool = false,
/// sticky goal column for j/k runs (helix old_visual_position): any
/// non-vertical range write resets it to -1.
sticky_col: i32 = -1,
-append_at: ?struct { row: i32, col: i32 } = null,
+/// The insert session began with `a`: on Esc every range gives back the
+/// one character it was stretched by (helix restore_cursor).
+restore_cursor: bool = false,
/// insert `Ctrl-r` was pressed: the next key names the register to insert
reg_wait: bool = false,
normal: modal.Normal.State = .{},
diff --git a/src/edit.zig b/src/edit.zig
index 14d8b7ed..8f6f09c6 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -344,28 +344,20 @@ pub fn exitInsert(p: *Pardes, t: *Text) void {
t.reg_wait = false;
t.msel.active = false;
t.normal.clear();
- if (t.append_at) |aa| {
- t.append_at = null;
- const text = flatSurface(p, t) catch return;
- const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
- const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) });
- const back = @max(a_off, modal.prevGrapheme(text, gap));
- const bc = modal.positionAt(text, back);
- t.cur_row = @intCast(bc.row);
- t.cur_col = @intCast(bc.col);
- t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false };
- t.cur_pinned = true;
- t.show();
- for (t.sels[0..t.nsel]) |*s| {
- const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) });
- const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap);
- const bp = modal.positionAt(text, b2);
- s.row = @intCast(bp.row);
- s.col = @intCast(bp.col);
- s.arow = s.row;
- s.acol = s.col;
- }
+ if (!t.restore_cursor) return;
+ t.restore_cursor = false;
+ // helix enter_normal_mode: every range, from its start to the char
+ // before its end when it points forward, else to its end
+ const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active;
+ const text = flatSurface(p, t) catch return;
+ var rs: [Text.max_selections]modal.Selection = undefined;
+ const got = t.ranges(text, 0, &rs);
+ for (rs[0..got.n]) |*r| {
+ const from = @min(r.anchor, r.head);
+ const to = @max(r.anchor, r.head);
+ r.* = .{ .anchor = from, .head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to };
}
+ t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl);
}
pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 {
@@ -518,7 +510,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
// `u` undoes the whole session and restores what was selected
pushUndo(p, t);
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.sticky_col = -1;
const text = flatSurface(p, t) catch {
t.mode = .insert;
@@ -544,12 +536,14 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void {
const lo_col = if (b) |bb| bb.lo_col else t.cur_col;
const hi_row = if (b) |bb| bb.hi_row else t.cur_row;
const hi_col = if (b) |bb| bb.hi_col else t.cur_col;
- t.append_at = .{ .row = lo_row, .col = lo_col };
+ t.restore_cursor = true;
const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }));
const gc = modal.positionAt(text, gap);
t.cur_row = @intCast(gc.row);
t.cur_col = @intCast(gc.col);
- t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false };
+ // helix append_mode: the range reaches one past its end, which
+ // is where typing goes; a bare cursor becomes a range too
+ t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false };
t.cur_pinned = true;
},
.line_start => {
@@ -634,6 +628,72 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
t.reg_wait = true;
return;
}
+ // helix maps every range through each insert-mode edit (Range::map):
+ // the edits below work on the bare cursor, and the range the cursor
+ // heads is carried through what they did to the text afterwards
+ if (!t.vsel.active) return insertEdit(p, t, key);
+ const old = flatSurface(p, t) catch return insertEdit(p, t, key);
+ const r = t.primaryRange(old, 0);
+ const c = modal.selectionCursor(old, r);
+ const old_len = old.len;
+ const explicit = t.vsel.explicit;
+ t.vsel.active = false;
+ insertEdit(p, t, key);
+ if (t.mode != .insert) return;
+ const new = flatSurface(p, t) catch return;
+ const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ // every insert-mode edit happens at the cursor: an insertion there, or
+ // a deletion that ends there (backward) or starts there (forward)
+ if (key.cp == Key.enter) {
+ // helix insert_newline: an appending range stretches by what the
+ // cursor moved, any other slides by it
+ const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c));
+ const head: usize = @intCast(@as(isize, @intCast(r.head)) + d);
+ const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d);
+ return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit);
+ }
+ var at: usize = c;
+ var del: usize = 0;
+ var ins: usize = 0;
+ if (new.len > old_len and c2 == c + (new.len - old_len)) {
+ ins = new.len - old_len;
+ } else if (new.len < old_len and c2 + (old_len - new.len) == c) {
+ at = c2;
+ del = old_len - new.len;
+ } else if (new.len < old_len and c2 == c) {
+ del = old_len - new.len;
+ } else if (new.len != old_len or c2 != c) {
+ return; // a motion: the range collapses onto the cursor (helix)
+ }
+ // helix Assoc: the head sticks after an insertion at it when the range
+ // points backward or is empty, the anchor when it points forward
+ const fwd = r.anchor < r.head;
+ setInsertRange(t, new, .{
+ .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head),
+ .head = mapThroughEdit(r.head, at, del, ins, fwd),
+ }, explicit);
+}
+
+fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void {
+ const cells = Text.rangeCells(new, mapped);
+ const cc = modal.positionAt(new, cells.cur);
+ const ac = modal.positionAt(new, cells.anc);
+ t.cur_row = @intCast(cc.row);
+ t.cur_col = @intCast(cc.col);
+ t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit };
+}
+
+/// A position through one edit at `at` that deleted `del` bytes and put
+/// `ins` in (one of the two is 0). A position exactly at an insertion stays
+/// before it when `before` is set, else moves past it.
+fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize {
+ if (pos < at) return pos;
+ if (del > 0) return if (pos < at + del) at else pos - del;
+ if (pos == at and before) return pos;
+ return pos + ins;
+}
+
+fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
t.vsel.active = false;
if (!t.cur_pinned) pinPaneCursor(p, t);
_ = clampFileCursor(t);
@@ -748,12 +808,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void {
switch (key.cp) {
Key.enter => {
const line = modal.lineSlice(text, c.row);
+ const before = line[0..@min(c.col, line.len)];
+ // helix insert_newline: after nothing but blanks, the line
+ // itself moves down, blanks and all; else the blanks before the
+ // cursor go and the new line takes the indent
+ if (std.mem.indexOfNone(u8, before, " \t") == null) {
+ const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return;
+ setEditText(p, t, new);
+ t.cur_row += 1;
+ t.cur_pinned = true;
+ t.show();
+ return;
+ }
const indent = modal.newlineIndentWidth(line, c.col);
const arena = p.scratch.allocator();
const block = arena.alloc(u8, 1 + indent) catch return;
block[0] = '\n';
@memset(block[1..], ' ');
- const new = modal.insertAt(p.gpa, text, c, block) catch return;
+ // a tag keeps its blanks: they part its words, and the last
+ // may be its read-only prefix's
+ const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col;
+ const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return;
+ defer p.gpa.free(cut);
+ const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return;
setEditText(p, t, new);
t.cur_row += 1;
t.cur_col = @intCast(indent);
diff --git a/src/look.zig b/src/look.zig
index 5e2d6068..30fb0dab 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1016,7 +1016,7 @@ pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void {
pane.body.nsel = 0;
pane.body.select = false;
pane.body.normal.clear();
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
pane.sel = @splat(.{});
diff --git a/src/mouse.zig b/src/mouse.zig
index 91ec41a6..d1c32b40 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -747,7 +747,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
pane.body.vsel.active = false;
pane.body.nsel = 0;
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
pane.body.normal.clear();
edit.setYank(p, text);
@@ -859,7 +859,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.body.vsel.active = false;
pane.body.nsel = 0; // a click says WHERE the one cursor is
pane.body.select = false;
- pane.body.append_at = null;
+ pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
diff --git a/src/normal.zig b/src/normal.zig
index f5e868a4..53e416fd 100644
--- a/src/normal.zig
+++ b/src/normal.zig
@@ -149,7 +149,7 @@ const KeyState = struct {
normal: modal.Normal.State,
find_op: u8,
find_ch: u21,
- append_at: @FieldType(Text, "append_at"),
+ restore_cursor: bool,
reg_wait: bool,
fn of(t: *Text) KeyState {
@@ -159,7 +159,7 @@ const KeyState = struct {
.normal = t.normal,
.find_op = t.find_op,
.find_ch = t.find_ch,
- .append_at = t.append_at,
+ .restore_cursor = t.restore_cursor,
.reg_wait = t.reg_wait,
};
}
@@ -170,7 +170,7 @@ const KeyState = struct {
t.normal = s.normal;
t.find_op = s.find_op;
t.find_ch = s.find_ch;
- t.append_at = s.append_at;
+ t.restore_cursor = s.restore_cursor;
t.reg_wait = s.reg_wait;
}
};
diff --git a/src/tagline.zig b/src/tagline.zig
index 14bf5677..1c060a66 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -219,7 +219,7 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
t.msel.active = false;
t.nsel = 0;
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.sticky_col = -1;
t.mode = .normal;
t.normal.clear();
@@ -325,7 +325,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void {
t.msel.active = false;
t.nsel = 0;
t.select = false;
- t.append_at = null;
+ t.restore_cursor = false;
t.mode = .normal;
t.normal.clear();
}
diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl
index 7ba44f09..ba75c611 100644
--- a/test/hxcases/cases.jsonl
+++ b/test/hxcases/cases.jsonl
@@ -344,6 +344,21 @@
{"name": "i-keeps-sel-start", "pane": "file", "text": "alpha beta\n", "keys": "wi"}
{"name": "wa-sel-end", "pane": "file", "text": "alpha beta\n", "keys": "wa"}
{"name": "wiX-edit-drops-sel", "pane": "file", "text": "alpha beta\n", "keys": "wiX<esc>"}
+{"name": "reg-selection-insert", "pane": "file", "text": "ab\n", "keys": "vli<C-r>.<esc>"}
+{"name": "ins-sel-backspace", "pane": "file", "text": "alpha beta\n", "keys": "wi<backspace><esc>"}
+{"name": "ins-append-enter", "pane": "file", "text": "ab cd\n", "keys": "wa<ret>x<esc>"}
+{"name": "ins-append-backspace", "pane": "file", "text": "ab cd\n", "keys": "ea<backspace><backspace><esc>"}
+{"name": "ins-sel-delete", "pane": "file", "text": "ab cd\n", "keys": "wi<del><esc>"}
+{"name": "ins-sel-arrow-collapses", "pane": "file", "text": "ab cd\n", "keys": "wi<right><esc>"}
+{"name": "msel-insert-keeps-ranges", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCiX<esc>"}
+{"name": "msel-append-lines", "pane": "file", "text": "aa\nbb\n", "keys": "%<A-s>a!<esc>"}
+{"name": "ins-append-bare", "pane": "file", "text": "ab\n", "keys": "axy<esc>"}
+{"name": "ins-change-then-type", "pane": "file", "text": "ab cd\n", "keys": "wcX<esc>"}
+{"name": "ins-open-below-sel", "pane": "file", "text": "ab\ncd\n", "keys": "xoX<esc>"}
+{"name": "ins-enter-trims-blanks", "pane": "file", "text": "ab cd\n", "keys": "5li<ret><esc>"}
+{"name": "ins-enter-in-indent", "pane": "file", "text": " ab\n", "keys": "2li<ret><esc>"}
+{"name": "ins-enter-after-open", "pane": "file", "text": " ab\n", "keys": "o<ret>x<esc>"}
+{"name": "msel-ins-enter", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCa<ret><esc>"}
{"name": "a-esc-restore", "pane": "file", "text": "abc\n", "keys": "a<esc>"}
{"name": "ax-restore", "pane": "file", "text": "abc\n", "keys": "ax<esc>"}
{"name": "lax-restore", "pane": "file", "text": "abc\n", "keys": "lax<esc>"}
diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl
index f6a6aada..188b7ce4 100644
--- a/test/hxcases/goldens.jsonl
+++ b/test/hxcases/goldens.jsonl
@@ -344,6 +344,21 @@
{"name":"i-keeps-sel-start","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}}
{"name":"wa-sel-end","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}}
{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}}
+{"name":"reg-selection-insert","text":"abab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":3}}
+{"name":"ins-sel-backspace","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}}
+{"name":"ins-append-enter","text":"ab\nxcd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-append-backspace","text":" cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
+{"name":"ins-sel-delete","text":"b cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}}
+{"name":"ins-sel-arrow-collapses","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"msel-insert-keeps-ranges","text":"Xab cd\nXab cd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3}}],"primary":1}
+{"name":"msel-append-lines","text":"aa!\nbb!\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":0}
+{"name":"ins-append-bare","text":"axyb\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}}
+{"name":"ins-change-then-type","text":"Xcd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}
+{"name":"ins-open-below-sel","text":"ab\nX\ncd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}
+{"name":"ins-enter-trims-blanks","text":"ab\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}
+{"name":"ins-enter-in-indent","text":"\n ab\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}
+{"name":"ins-enter-after-open","text":" ab\n\n x\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}}
+{"name":"msel-ins-enter","text":"ab\ncd\nab\ncd\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":1}
{"name":"a-esc-restore","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}
{"name":"ax-restore","text":"axbc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}}
{"name":"lax-restore","text":"abxc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}}
diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl
index 8ac3bd9c..2d74e346 100644
--- a/test/hxcases/waivers.jsonl
+++ b/test/hxcases/waivers.jsonl
@@ -1,3 +1 @@
-{"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}}
{"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}}
-{"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}}
diff --git a/test/mode.zig b/test/mode.zig
index c7d7d548..cba93c30 100644
--- a/test/mode.zig
+++ b/test/mode.zig
@@ -79,14 +79,14 @@ test "Mode from actual tag Exec preserves parked file and output insert transact
p.update(.{ .key = .{ .cp = pardes.Key.tab } });
try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode);
try std.testing.expect(pane.focus == .body);
- try std.testing.expect(pane.body.append_at == null);
+ try std.testing.expect(!pane.body.restore_cursor);
p.update(.{ .key = .{ .cp = 'u' } });
try std.testing.expectEqualStrings("ab\n", pane.file.?.content);
p.update(.{ .key = .{ .cp = 'a' } });
p.update(.{ .key = .{ .cp = 'z', .text = "z" } });
- try std.testing.expect(pane.body.append_at != null);
+ try std.testing.expect(pane.body.restore_cursor);
try mode(p, 0, .normal);
- try std.testing.expect(pane.body.append_at == null);
+ try std.testing.expect(!pane.body.restore_cursor);
p.update(.{ .key = .{ .cp = 'u' } });
try std.testing.expectEqualStrings("ab\n", pane.file.?.content);
}
diff --git a/test/panes.zig b/test/panes.zig
index cbf9d097..4e98f449 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -4547,7 +4547,7 @@ const LookResetTests = struct {
pane.body.nsel = 1;
pane.body.select = true;
pane.body.normal.count = 5;
- pane.body.append_at = .{ .row = 0, .col = 3 };
+ pane.body.restore_cursor = true;
pane.body.sticky_col = 7;
for (0..pane.sel.len) |slot| {
pane.clearPointerSelection(slot);
@@ -4567,7 +4567,7 @@ const LookResetTests = struct {
try std.testing.expectEqual(@as(u8, 0), pane.body.nsel);
try std.testing.expect(!pane.body.select);
try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal);
- try std.testing.expect(pane.body.append_at == null);
+ try std.testing.expect(!pane.body.restore_cursor);
try std.testing.expectEqual(@as(i32, -1), pane.body.sticky_col);
for (pane.sel) |selection| try std.testing.expectEqual(.none, selection.state);
for (pane.pointer_selections) |selection| try std.testing.expect(selection == null);
diff --git a/transactions.txt b/transactions.txt
index 6c2e7646..8d2fda3f 100644
--- a/transactions.txt
+++ b/transactions.txt
@@ -1,13 +1,8 @@
-Selection anchors through insert-mode edits, and the transaction model
-=======================================================================
-
-STATUS: still open. `wiX-edit-drops-sel` is line 1 of test/hxcases/waivers.jsonl,
-so the gap below describes the code as it stands rather than something already
-fixed. One correction to the sentence that follows: it is not quite "the one
-place". Line 3 of the same file, `msel-append`, says in its own words "Same
-family as wiX-edit-drops-sel: pardes does not map selections through insert-mode
-edits" — so the gap has two waivers, and what is singular is the CAUSE below,
-not the number of divergences it produces.
+STATUS: closed for the waivers. Insert mode now carries every range through
+each edit (edit.insertKey maps the range with helix's Range::map rules after
+the edit is made), so `wiX-edit-drops-sel` and `msel-append` are gone. What
+follows is the original analysis; the delta layer it proposes was not needed
+for that and stays unbuilt.
The one place the helix differential suite shows a real structural gap
(waiver "wiX-edit-drops-sel" in test/hxcases/waivers.jsonl): helix keeps a