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 --- src/Pipe.zig | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/config.zig | 4 +++- src/modal.zig | 7 ++++++- src/tutor.txt | 2 +- 4 files changed, 71 insertions(+), 5 deletions(-) (limited to 'src') 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 -- cgit v1.3