From 06fe2290db7d0eb654b9a908358942cfde298e29 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:15:15 -0300 Subject: $ keeps the selections a shell command exits 0 on, and line end is gl $ was a second key for line end, which helix never meant: in helix it is shell_keep_pipe. It now arms the pipe prompt with a $ marker, runs the command once per range with that range's text on stdin, and keeps the ranges it exits 0 on; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing. The runner's answer is all or nothing, so the command runs in a subshell whose status is echoed as its output. Line end stays on helix's own gl and End; the snapshots, tutor and docs that pressed $ for it press gl. Co-Authored-By: Claude Opus 5.5 --- docs/design.typ | 2 +- docs/helix-keys.md | 4 +-- src/Pipe.zig | 63 +++++++++++++++++++++++++++++++++++++++++++-- src/config.zig | 4 ++- src/modal.zig | 7 ++++- src/tutor.txt | 2 +- test/panes.zig | 6 ++++- test/pdf.zig | 3 ++- test/snapshots/badutf.snap | 2 +- test/snapshots/hscroll.snap | 4 +-- test/snapshots/tagnav.snap | 4 +-- 11 files changed, 86 insertions(+), 15 deletions(-) diff --git a/docs/design.typ b/docs/design.typ index 234c3692..4765fabd 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1916,7 +1916,7 @@ delayed, theme-derived highlight of the exact side-effect-free selection that Look would expand; it neither focuses nor installs that selection, and pointer leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched. -*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and +*Modes.* normal: helix motions (`h j k l w b e W B E 0 gl ^`, `f F t T` and `Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`), insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 14a39239..cd33ccbe 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -100,7 +100,7 @@ language-backend queries, and the shell pipe. | `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. It is the one shell command still unimplemented: it needs a per-selection verdict, where the runner's answer is atomic. `0` is a count digit while a count is pending | pardes-specific (`$`: deliberate) | +| `0` / `^` | line start / first non-ws | pardes extras: helix leaves both unbound (it spells them `gh`/`gs`), so they collide with nothing. Line end is helix's own `gl` and End. `0` is a count digit while a count is pending | pardes-specific | | `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 | @@ -146,6 +146,7 @@ language-backend queries, and the shell pipe. | `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus | | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | | `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus | +| `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus | ## B. To implement @@ -355,7 +356,6 @@ text. | `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-!` `$` | 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 | diff --git a/src/Pipe.zig b/src/Pipe.zig index 1ad7b826..37145edf 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -35,6 +35,7 @@ pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { .ignore => config.pipe_marker_to, .insert => config.pipe_marker_insert, .append => config.pipe_marker_append, + .keep => config.pipe_marker_keep, }; } @@ -117,7 +118,13 @@ pub fn submitPipe(p: *Pardes, id: usize) void { p.gpa.free(inputs); return; } - const command_copy = p.gpa.dupe(u8, command) catch { + // `$` wants a verdict per selection where the runner's is all or + // nothing: the command runs in a subshell whose exit status becomes the + // output, so the runner always succeeds and the answer says which kept + const command_copy = (if (how == .keep) + std.fmt.allocPrint(p.gpa, "(\n{s}\n) >/dev/null; echo $?", .{command}) + else + p.gpa.dupe(u8, command)) catch { for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; @@ -202,7 +209,7 @@ fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo const hi = @max(range.anchor, range.head); if (hi > content.len) return null; return switch (wait.how) { - .replace, .ignore => .{ .lo = lo, .hi = hi }, + .replace, .ignore, .keep => .{ .lo = lo, .hi = hi }, .insert => .{ .lo = lo, .hi = lo }, .append => .{ .lo = hi, .hi = hi }, }; @@ -260,6 +267,21 @@ pub fn pipeResponse( if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); if (wait.how == .ignore) return; + if (wait.how == .keep) { + // helix shell_keep_pipe: the primary stays if kept, else the last + // kept range takes over; keeping none changes nothing + var kept: [panes.Text.max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: ?usize = null; + for (0..wait.nranges) |i| { + if (!std.mem.eql(u8, std.mem.trim(u8, outputs[i], " \n"), "0")) continue; + if (i == wait.primary) pri = m; + kept[m] = wait.ranges[i]; + m += 1; + } + if (m == 0) return; + return t.setRanges(content, kept[0..m], &.{}, pri orelse m - 1, wait.explicit); + } const n = wait.nranges; var total_output: usize = 0; @@ -366,6 +388,43 @@ test "| in a tag pipes the tag's own text, and undo there brings it back" { try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save ")); } +test "$ keeps the selections the command exits 0 on, and a real shell gives that verdict" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 3, .head = 5 }, + .{ .anchor = 6, .head = 8 }, + }; + pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 1, true); + p.update(.{ .key = .{ .cp = '$' } }); + try std.testing.expectEqualStrings(config.pipe_marker_keep, pane.promptText().?); + p.update(.{ .key = .{ .cp = 'g', .text = "grep -v b" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest; + try std.testing.expectEqual(@as(usize, 3), request.inputs.len); + // the wrapper turns each run's status into its output, as the runner sees it + const job = try selection_pipe.Job.copy(gpa, request); + defer job.deinit(gpa); + var response = selection_pipe.runJob(gpa, std.testing.io, job); + defer response.deinit(gpa); + try std.testing.expect(response.success); + try std.testing.expectEqualStrings("0\n", response.outputs[0]); + try std.testing.expectEqualStrings("1\n", response.outputs[1]); + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = response.outputs } }); + try std.testing.expectEqualStrings("aa bb cc\n", pane.file.?.content); + var out: [panes.Text.max_selections]modal.Selection = undefined; + const got = pane.body.ranges(pane.file.?.content, 0, &out); + try std.testing.expectEqual(@as(usize, 2), got.n); + try std.testing.expectEqual(@as(usize, 6), out[1].anchor); + // the primary was dropped: the last kept range takes over + try std.testing.expectEqual(@as(usize, 1), got.pri); +} + test "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); diff --git a/src/config.zig b/src/config.zig index 4412ef0d..91665b14 100644 --- a/src/config.zig +++ b/src/config.zig @@ -266,10 +266,12 @@ pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; +pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; +pub const pipe_marker_keep = " $"; pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; // Del from the keyboard on a pane with open panes both above and below it @@ -454,7 +456,7 @@ pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }}; pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }}; pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; -pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; +pub const line_end: []const Chord = &.{.{ .cp = Key.end }}; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; // Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; diff --git a/src/modal.zig b/src/modal.zig index aec1c579..bce941d5 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -128,6 +128,7 @@ pub const Normal = struct { pipe_selection_to, insert_output, append_output, + keep_pipe, search, search_next, search_prev, @@ -196,10 +197,13 @@ pub const Normal = struct { insert, /// `A-!` — no stdin, and the output is appended AFTER each selection. append, + /// `$` — stdin is the selection; the output is discarded, and only the + /// selections the command exits 0 on are kept. + keep, /// Do the selections become stdin? helix's `pipe` flag. pub fn pipes(b: PipeBehavior) bool { - return b == .replace or b == .ignore; + return b == .replace or b == .ignore or b == .keep; } }; @@ -597,6 +601,7 @@ pub const Normal = struct { if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); + if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); diff --git a/src/tutor.txt b/src/tutor.txt index 507ce40e..0893415e 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -461,7 +461,7 @@ typed N frontends share ONE screen at the smallest common grid the default 9P socket works in a daemon too - KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p + KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p i a I A o O u undo U redo motions SELECT, so `i` types at the selection's start and `;` collapses first diff --git a/test/panes.zig b/test/panes.zig index 0b9909ad..f0e1ba3d 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -1954,7 +1954,11 @@ test "a prompt owns the pipe behavior through cancellation and submission" { p.update(.{ .key = .{ .cp = Key.enter } }); const pending = p.pipe.wait orelse return error.MissingPipe; try std.testing.expectEqual(how, pending.how); - try std.testing.expectEqualStrings("cat", pending.command); + // `$` runs the command in a subshell that reports its status + if (how == .keep) + try std.testing.expect(std.mem.indexOf(u8, pending.command, "\ncat\n") != null) + else + try std.testing.expectEqualStrings("cat", pending.command); try std.testing.expectEqual(@as(usize, 1), pending.inputs.len); try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes); try std.testing.expect(pane.prompt == .none); diff --git a/test/pdf.zig b/test/pdf.zig index 3a592451..8b94c1ae 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -859,7 +859,8 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expect(pane.pdf.?.pan_x > 0); - p.update(.{ .key = .{ .cp = '$' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'h' } }); diff --git a/test/snapshots/badutf.snap b/test/snapshots/badutf.snap index ddd1588b..7171df00 100644 --- a/test/snapshots/badutf.snap +++ b/test/snapshots/badutf.snap @@ -19,6 +19,6 @@ stable 700 20000 # first visible CJK glyph intact instead of starting inside its UTF-8 bytes. key space t w stable 400 5000 -key $ +key g l stable 700 10000 snap hscroll-midglyph diff --git a/test/snapshots/hscroll.snap b/test/snapshots/hscroll.snap index 6f73154f..05366323 100644 --- a/test/snapshots/hscroll.snap +++ b/test/snapshots/hscroll.snap @@ -36,10 +36,10 @@ send \x1b[<67;10;8M send \x1b[<67;10;8M stable 400 5000 snap clamped -# $ walks the cursor to the line end: the view follows with the margin +# gl walks the cursor to the line end: the view follows with the margin key g g stable 400 5000 -key $ +key g l stable 400 5000 snap dollar # 0 back to column zero: the view snaps home diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index 95c4b751..f145480e 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -98,7 +98,7 @@ snap workspace-tag # lives up here too, so a builtin that frees the session must never return # into a focused header. Dump grows a `Restore ` word, which is also the # case where the line gets longer than the column you were parked on. -key $ +key g l stable 400 5000 snap topbar-dollar key B B B B @@ -114,7 +114,7 @@ snap topbar-exec key c-w k key c-w k stable 400 5000 -key $ B B +key g l B B stable 400 5000 snap topbar-restore-word key tab -- cgit v1.3