From 59f4fd28debd6178bdd224d018f9079a5b6c8002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Fri, 26 Jun 2026 16:44:32 +1200 Subject: [PATCH 1/6] Activate REPL history --- src/cli/ReplLine.zig | 17 +++++++++++++++++ src/cli/main.zig | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/cli/ReplLine.zig b/src/cli/ReplLine.zig index 5389690666b..1d17bbad2da 100644 --- a/src/cli/ReplLine.zig +++ b/src/cli/ReplLine.zig @@ -203,6 +203,10 @@ const History = struct { } pub fn append(self: *History, input: []const u8) Allocator.Error!void { + if (self.entries.items.len > 0) { + const last = self.entries.items[self.entries.items.len - 1]; + if (std.mem.eql(u8, last, input)) return; + } const input_copy = try self.allocator.alloc(u8, input.len); @memcpy(input_copy, input); try self.entries.append(self.allocator, input_copy); @@ -968,3 +972,16 @@ test "writeAlignedToPrompt: original indentation is preserved on top of prompt i test "writeAlignedToPrompt: trailing newline still emits indent for empty next line" { try expectAlignedOutput("z = 5\n", 2, "z = 5\n "); } + +test "History: basic appending and deduplication" { + var history = History.init(testing.allocator); + defer history.deinit(); + + try history.append("x = 1"); + try history.append("y = 2"); + try history.append("y = 2"); // should be ignored as consecutive duplicate + + try testing.expectEqual(@as(usize, 2), history.entries.items.len); + try testing.expectEqualStrings("x = 1", history.entries.items[0]); + try testing.expectEqualStrings("y = 2", history.entries.items[1]); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 247f5dab7cd..08b4c70751a 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11232,6 +11232,7 @@ fn rocRepl(ctx: *CliCtx, repl_args: cli_args.ReplArgs) CliMainError!void { } if (pending.items.len == 0 and std.mem.findAny(u8, raw_line, "\n\r") != null) { + try reader.history.append(raw_line); should_exit = try processReplInput(ctx, &session, raw_line, report_config, &had_diagnostics); continue; } @@ -11242,6 +11243,7 @@ fn rocRepl(ctx: *CliCtx, repl_args: cli_args.ReplArgs) CliMainError!void { switch (try session.inputStatus(pending.items)) { .incomplete => {}, .complete, .invalid => { + try reader.history.append(pending.items); should_exit = try processReplInput(ctx, &session, pending.items, report_config, &had_diagnostics); pending.clearRetainingCapacity(); }, From 86efe485b4b8f34abcd2280baa5526a24e5c47b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Fri, 26 Jun 2026 17:04:32 +1200 Subject: [PATCH 2/6] Return to current line when moving up and down the history --- src/cli/ReplLine.zig | 69 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli/ReplLine.zig b/src/cli/ReplLine.zig index 1d17bbad2da..6b3f0dea14a 100644 --- a/src/cli/ReplLine.zig +++ b/src/cli/ReplLine.zig @@ -248,6 +248,7 @@ const LineState = struct { in_buffer: [8]u8, history: *History, history_index: ?usize, + transient_line: ?[]const u8, /// Set after a Ctrl-C so that a second consecutive Ctrl-C quits. Any other /// input event clears it, so the two presses must be back-to-back. ctrl_c_armed: bool, @@ -334,6 +335,7 @@ fn historyBackward(state: *LineState) CommandError!void { if (hist_len == 0) return; if (state.history_index == null) { + state.transient_line = try state.temp.dupe(u8, state.line_buffer.items); state.history_index = hist_len - 1; } else if (state.history_index.? > 0) { state.history_index = state.history_index.? - 1; @@ -361,10 +363,15 @@ fn historyForward(state: *LineState) CommandError!void { try state.line_buffer.appendSlice(state.temp, entry); state.col_offset = entry.len; } else { - // Past the end, clear line + // Past the end, restore transient draft line state.history_index = null; state.line_buffer.clearAndFree(state.temp); - state.col_offset = 0; + if (state.transient_line) |transient| { + try state.line_buffer.appendSlice(state.temp, transient); + state.col_offset = transient.len; + } else { + state.col_offset = 0; + } } try ansi_term.setCursorColumn(state.out, state.prompt_width); @@ -498,6 +505,7 @@ fn helper(self: *ReplLine, outlive: Allocator, std_io: std.Io, prompt: []const u .in_buffer = undefined, .history = &self.history, .history_index = null, + .transient_line = null, .ctrl_c_armed = false, }; @@ -985,3 +993,60 @@ test "History: basic appending and deduplication" { try testing.expectEqualStrings("x = 1", history.entries.items[0]); try testing.expectEqualStrings("y = 2", history.entries.items[1]); } + +test "History: transient line preservation" { + var history = History.init(testing.allocator); + defer history.deinit(); + + try history.append("first command"); + try history.append("second command"); + + var arena = base.SingleThreadArena.init(testing.allocator); + defer arena.deinit(); + const temp = arena.allocator(); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + + var state = LineState{ + .outlive = testing.allocator, + .temp = temp, + .prompt = "> ", + .prompt_width = 2, + .out = &aw.writer, + .in = undefined, // not used in history functions + .col_offset = 0, + .line_buffer = std.ArrayList(u8).empty, + .bytes_read = 0, + .in_buffer = undefined, + .history = &history, + .history_index = null, + .transient_line = null, + .ctrl_c_armed = false, + }; + + // Simulate typing a transient command "third draft" + try state.line_buffer.appendSlice(temp, "third draft"); + state.col_offset = 11; + + // Navigate back to history (second command) + try historyBackward(&state); + try testing.expectEqualStrings("second command", state.line_buffer.items); + try testing.expectEqualStrings("third draft", state.transient_line.?); + try testing.expectEqual(@as(?usize, 1), state.history_index); + + // Navigate further back (first command) + try historyBackward(&state); + try testing.expectEqualStrings("first command", state.line_buffer.items); + try testing.expectEqual(@as(?usize, 0), state.history_index); + + // Navigate forward (second command) + try historyForward(&state); + try testing.expectEqualStrings("second command", state.line_buffer.items); + try testing.expectEqual(@as(?usize, 1), state.history_index); + + // Navigate past the end (restores transient command "third draft") + try historyForward(&state); + try testing.expectEqualStrings("third draft", state.line_buffer.items); + try testing.expect(state.history_index == null); +} From 5e15b75e79d5d223fde7c73ce61b405988b29bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Fri, 26 Jun 2026 17:51:29 +1200 Subject: [PATCH 3/6] Add standard Ctrl and Alt commands for line navigation and editing --- src/cli/ReplLine.zig | 318 ++++++++++++++++++++++++++++++++++++++++++- src/cli/Unix.zig | 2 + 2 files changed, 313 insertions(+), 7 deletions(-) diff --git a/src/cli/ReplLine.zig b/src/cli/ReplLine.zig index 5389690666b..0e2ed544c38 100644 --- a/src/cli/ReplLine.zig +++ b/src/cli/ReplLine.zig @@ -30,6 +30,8 @@ pub const NEW_LINE = switch (SUPPORTED_OS) { pub const InputEvent = union(enum) { /// A single byte to be processed normally (typed character, control key, etc.). byte: u8, + /// A 2-byte ESC sequence (ESC X) โ€” typically Alt-key combinations. + esc2: [2]u8, /// A 3-byte CSI sequence (ESC [ X) โ€” typically arrow keys. csi3: [3]u8, /// A bracketed paste began (consumed an `ESC[200~` marker). @@ -143,6 +145,10 @@ pub const InputParser = struct { try events.append(gpa, .{ .csi3 = .{ buf[i], buf[i + 1], buf[i + 2] } }); i += 3; + } else if (key == control_code.esc and i + 1 < total and buf[i + 1] != '[') { + // 2-byte ESC sequence (ESC X) + try events.append(gpa, .{ .esc2 = .{ buf[i], buf[i + 1] } }); + i += 2; } else if (key == control_code.esc and i + 1 >= total) { // Lone ESC at the end of the buffer โ€” could be the start of a // longer sequence whose tail is in the next chunk. @@ -214,13 +220,21 @@ const ReplLine = @This(); allocator: Allocator, history: History, +kill_ring: ?[]const u8, pub fn init(allocator: Allocator) ReplLine { - return ReplLine{ .allocator = allocator, .history = History.init(allocator) }; + return ReplLine{ + .allocator = allocator, + .history = History.init(allocator), + .kill_ring = null, + }; } pub fn deinit(self: *ReplLine) void { self.history.deinit(); + if (self.kill_ring) |k| { + self.allocator.free(k); + } } const CommandError = @@ -244,6 +258,8 @@ const LineState = struct { in_buffer: [8]u8, history: *History, history_index: ?usize, + transient_line: ?[]const u8, + kill_ring: *?[]const u8, /// Set after a Ctrl-C so that a second consecutive Ctrl-C quits. Any other /// input event clears it, so the two presses must be back-to-back. ctrl_c_armed: bool, @@ -325,6 +341,142 @@ fn moveCursorLeft(state: *LineState) CommandError!void { try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); } +fn moveCursorToStart(state: *LineState) CommandError!void { + state.col_offset = 0; + try ansi_term.setCursorColumn(state.out, state.prompt_width); +} + +fn moveCursorToEnd(state: *LineState) CommandError!void { + state.col_offset = state.line_buffer.items.len; + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + +fn killLineToEnd(state: *LineState) CommandError!void { + const cut_text = state.line_buffer.items[state.col_offset..]; + if (cut_text.len > 0) { + if (state.kill_ring.*) |prev| { + state.outlive.free(prev); + } + state.kill_ring.* = try state.outlive.dupe(u8, cut_text); + } + state.line_buffer.shrinkAndFree(state.temp, state.col_offset); + try ansi_term.clearFromCursorToLineEnd(state.out); +} + +fn isWordChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_'; +} + +fn findWordStartBackward(buf: []const u8, start: usize) usize { + var i = start; + while (i > 0 and !isWordChar(buf[i - 1])) : (i -= 1) {} + while (i > 0 and isWordChar(buf[i - 1])) : (i -= 1) {} + return i; +} + +fn deleteToStart(state: *LineState) CommandError!void { + if (state.col_offset == 0) return; + + const cut_text = state.line_buffer.items[0..state.col_offset]; + if (state.kill_ring.*) |prev| { + state.outlive.free(prev); + } + state.kill_ring.* = try state.outlive.dupe(u8, cut_text); + + const remaining_len = state.line_buffer.items.len - state.col_offset; + std.mem.copyForwards(u8, state.line_buffer.items[0..remaining_len], state.line_buffer.items[state.col_offset..]); + state.line_buffer.shrinkRetainingCapacity(remaining_len); + state.col_offset = 0; + + try ansi_term.setCursorColumn(state.out, state.prompt_width); + try state.out.writeAll(state.line_buffer.items); + try ansi_term.clearFromCursorToLineEnd(state.out); + try ansi_term.setCursorColumn(state.out, state.prompt_width); +} + +fn deleteWordBackward(state: *LineState) CommandError!void { + if (state.col_offset == 0) return; + + const word_start = findWordStartBackward(state.line_buffer.items, state.col_offset); + if (word_start == state.col_offset) return; + + const cut_text = state.line_buffer.items[word_start..state.col_offset]; + if (state.kill_ring.*) |prev| { + state.outlive.free(prev); + } + state.kill_ring.* = try state.outlive.dupe(u8, cut_text); + + const remaining_len = state.line_buffer.items.len - (state.col_offset - word_start); + std.mem.copyForwards( + u8, + state.line_buffer.items[word_start..remaining_len], + state.line_buffer.items[state.col_offset..], + ); + state.line_buffer.shrinkRetainingCapacity(remaining_len); + state.col_offset = word_start; + + try ansi_term.setCursorColumn(state.out, state.prompt_width); + try state.out.writeAll(state.line_buffer.items); + try ansi_term.clearFromCursorToLineEnd(state.out); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + +fn yank(state: *LineState) CommandError!void { + const text = state.kill_ring.* orelse return; + if (text.len == 0) return; + + try state.line_buffer.insertSlice(state.temp, state.col_offset, text); + state.col_offset += text.len; + + try ansi_term.setCursorColumn(state.out, state.prompt_width); + try state.out.writeAll(state.line_buffer.items); + try ansi_term.clearFromCursorToLineEnd(state.out); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + +fn findWordEndForward(buf: []const u8, start: usize) usize { + var i = start; + const len = buf.len; + while (i < len and !isWordChar(buf[i])) : (i += 1) {} + while (i < len and isWordChar(buf[i])) : (i += 1) {} + return i; +} + +fn moveWordLeft(state: *LineState) CommandError!void { + state.col_offset = findWordStartBackward(state.line_buffer.items, state.col_offset); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + +fn moveWordRight(state: *LineState) CommandError!void { + state.col_offset = findWordEndForward(state.line_buffer.items, state.col_offset); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + +fn killWordForward(state: *LineState) CommandError!void { + const word_end = findWordEndForward(state.line_buffer.items, state.col_offset); + if (word_end == state.col_offset) return; + + const cut_text = state.line_buffer.items[state.col_offset..word_end]; + if (state.kill_ring.*) |prev| { + state.outlive.free(prev); + } + state.kill_ring.* = try state.outlive.dupe(u8, cut_text); + + const cut_len = word_end - state.col_offset; + const remaining_len = state.line_buffer.items.len - cut_len; + std.mem.copyForwards( + u8, + state.line_buffer.items[state.col_offset..remaining_len], + state.line_buffer.items[word_end..], + ); + state.line_buffer.shrinkRetainingCapacity(remaining_len); + + try ansi_term.setCursorColumn(state.out, state.prompt_width); + try state.out.writeAll(state.line_buffer.items); + try ansi_term.clearFromCursorToLineEnd(state.out); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); +} + fn historyBackward(state: *LineState) CommandError!void { const hist_len = state.history.entries.items.len; if (hist_len == 0) return; @@ -377,6 +529,15 @@ fn findCommandFn(state: *LineState) CommandFn { ansi_term.ctrlKey('D') => exitRepl, ansi_term.ctrlKey('L') => clearScreen, ansi_term.ctrlKey('C') => handleCtrlC, + ansi_term.ctrlKey('A') => moveCursorToStart, + ansi_term.ctrlKey('E') => moveCursorToEnd, + ansi_term.ctrlKey('K') => killLineToEnd, + ansi_term.ctrlKey('U') => deleteToStart, + ansi_term.ctrlKey('W') => deleteWordBackward, + ansi_term.ctrlKey('Y') => yank, + ansi_term.ctrlKey('B') => moveCursorLeft, + ansi_term.ctrlKey('F') => moveCursorRight, + ansi_term.ctrlKey('H') => deleteBefore, control_code.lf, control_code.cr => acceptLine, control_code.esc => { if (state.bytes_read >= 3 and state.in_buffer[1] == '[') { @@ -387,6 +548,13 @@ fn findCommandFn(state: *LineState) CommandFn { ansi_term.DOWN => historyForward, else => doNothing, }; + } else if (state.bytes_read == 2) { + return switch (state.in_buffer[1]) { + 'b', 'B' => moveWordLeft, + 'f', 'F' => moveWordRight, + 'd', 'D' => killWordForward, + else => doNothing, + }; } else { return doNothing; } @@ -494,6 +662,8 @@ fn helper(self: *ReplLine, outlive: Allocator, std_io: std.Io, prompt: []const u .in_buffer = undefined, .history = &self.history, .history_index = null, + .transient_line = null, + .kill_ring = &self.kill_ring, .ctrl_c_armed = false, }; @@ -550,6 +720,11 @@ fn helper(self: *ReplLine, outlive: Allocator, std_io: std.Io, prompt: []const u state.in_buffer[0] = b; state.bytes_read = 1; }, + .esc2 => |seq| { + state.in_buffer[0] = seq[0]; + state.in_buffer[1] = seq[1]; + state.bytes_read = 2; + }, .csi3 => |seq| { state.in_buffer[0] = seq[0]; state.in_buffer[1] = seq[1]; @@ -669,16 +844,13 @@ test "InputParser: 3-byte CSI split as ESC then [A" { try testing.expectEqual(@as(usize, 0), parser.carry_len); } -test "InputParser: bare ESC followed by non-[ bytes is flushed as bytes" { - // Once a non-`[` byte appears after ESC, ESC and the trailing bytes are - // emitted as plain `byte` events (the existing 3-byte CSI path is the - // only one that consumes ESC specially). +test "InputParser: bare ESC followed by non-[ bytes is parsed as a 2-byte ESC sequence" { + // Once a non-`[` byte appears after ESC, it is parsed as a 2-byte ESC sequence. var parser = InputParser{}; var events = try collectEvents(&parser, &.{"\x1bOP"}); defer events.deinit(testing.allocator); try expectEventsEqual(&.{ - .{ .byte = 0x1b }, - .{ .byte = 'O' }, + .{ .esc2 = .{ 0x1b, 'O' } }, .{ .byte = 'P' }, }, events.items); try testing.expectEqual(@as(usize, 0), parser.carry_len); @@ -968,3 +1140,135 @@ test "writeAlignedToPrompt: original indentation is preserved on top of prompt i test "writeAlignedToPrompt: trailing newline still emits indent for empty next line" { try expectAlignedOutput("z = 5\n", 2, "z = 5\n "); } + +test "Keyboard commands: advanced bindings" { + var line_buffer = std.ArrayList(u8).empty; + defer line_buffer.deinit(testing.allocator); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + + var kill_ring: ?[]const u8 = null; + defer if (kill_ring) |k| testing.allocator.free(k); + + var state = LineState{ + .outlive = testing.allocator, + .temp = testing.allocator, + .prompt = "ยป ", + .prompt_width = 2, + .out = &aw.writer, + .in = undefined, + .col_offset = 0, + .line_buffer = line_buffer, + .bytes_read = 0, + .in_buffer = undefined, + .history = undefined, + .history_index = null, + .transient_line = null, + .kill_ring = &kill_ring, + .ctrl_c_armed = false, + }; + defer state.line_buffer.deinit(testing.allocator); + + // Simulate typing "hello world" + try state.line_buffer.appendSlice(testing.allocator, "hello world"); + state.col_offset = 11; + + // Ctrl-A: move cursor to start + try moveCursorToStart(&state); + try testing.expectEqual(@as(usize, 0), state.col_offset); + + // Ctrl-E: move cursor to end + try moveCursorToEnd(&state); + try testing.expectEqual(@as(usize, 11), state.col_offset); + + // Ctrl-B: move cursor left (backward) + try moveCursorLeft(&state); + try testing.expectEqual(@as(usize, 10), state.col_offset); + + // Ctrl-F: move cursor right (forward) + try moveCursorRight(&state); + try testing.expectEqual(@as(usize, 11), state.col_offset); + + // Ctrl-H / Backspace: delete character before cursor + try deleteBefore(&state); // deletes 'd' + try testing.expectEqualStrings("hello worl", state.line_buffer.items); + try testing.expectEqual(@as(usize, 10), state.col_offset); + + // Move cursor back to 5 (after "hello") + state.col_offset = 5; + + // Ctrl-K: kill line to end (kills " worl") + try killLineToEnd(&state); + try testing.expectEqualStrings("hello", state.line_buffer.items); + try testing.expectEqualStrings(" worl", kill_ring.?); + + // Ctrl-Y: yank/paste at cursor + try yank(&state); + try testing.expectEqualStrings("hello worl", state.line_buffer.items); + try testing.expectEqual(@as(usize, 10), state.col_offset); + + // Ctrl-W: delete word backward (deletes "worl") + try deleteWordBackward(&state); + try testing.expectEqualStrings("hello ", state.line_buffer.items); + try testing.expectEqualStrings("worl", kill_ring.?); + + // Test that Ctrl-W stops at parentheses and punctuation + try state.line_buffer.appendSlice(testing.allocator, "foo(bar_baz)"); + state.col_offset = 18; // pointing to end of line: "hello foo(bar_baz)" + + // Ctrl-W: should delete "bar_baz)" (word: "bar_baz", non-word: ")") + try deleteWordBackward(&state); + try testing.expectEqualStrings("hello foo(", state.line_buffer.items); + try testing.expectEqualStrings("bar_baz)", kill_ring.?); + + // Ctrl-W: should delete "foo(" (word: "foo", non-word: "(") + try deleteWordBackward(&state); + try testing.expectEqualStrings("hello ", state.line_buffer.items); + try testing.expectEqualStrings("foo(", kill_ring.?); + + // Ctrl-U: delete to start (deletes "hello ") + try deleteToStart(&state); + try testing.expectEqualStrings("", state.line_buffer.items); + try testing.expectEqualStrings("hello ", kill_ring.?); + + // Reset buffer to "hello foo(bar_baz)" for Alt-B, Alt-F, Alt-D testing + state.line_buffer.clearRetainingCapacity(); + try state.line_buffer.appendSlice(testing.allocator, "hello foo(bar_baz)"); + state.col_offset = 0; + + // Alt-F (moveWordRight) + try moveWordRight(&state); // moves past "hello" -> 5 (space before foo) + try testing.expectEqual(@as(usize, 5), state.col_offset); + + try moveWordRight(&state); // moves past "foo" -> 9 (parenthesis '(') + try testing.expectEqual(@as(usize, 9), state.col_offset); + + try moveWordRight(&state); // moves past "bar_baz" -> 17 (parenthesis ')') + try testing.expectEqual(@as(usize, 17), state.col_offset); + + // Alt-B (moveWordLeft) + try moveWordLeft(&state); // moves to start of "bar_baz" -> 10 + try testing.expectEqual(@as(usize, 10), state.col_offset); + + try moveWordLeft(&state); // moves to start of "foo" -> 6 + try testing.expectEqual(@as(usize, 6), state.col_offset); + + // Alt-D (killWordForward) + // currently at "hello foo(bar_baz)" with col_offset at 6 (start of "foo") + try killWordForward(&state); // should delete "foo" (word: "foo", non-word: "(" stops it) + try testing.expectEqualStrings("hello (bar_baz)", state.line_buffer.items); + try testing.expectEqualStrings("foo", kill_ring.?); + try testing.expectEqual(@as(usize, 6), state.col_offset); +} + +test "InputParser: 2-byte ESC sequence" { + var parser = InputParser{}; + var events = try collectEvents(&parser, &.{"\x1bb\x1bf\x1bd"}); + defer events.deinit(testing.allocator); + try expectEventsEqual(&.{ + .{ .esc2 = .{ 0x1b, 'b' } }, + .{ .esc2 = .{ 0x1b, 'f' } }, + .{ .esc2 = .{ 0x1b, 'd' } }, + }, events.items); +} diff --git a/src/cli/Unix.zig b/src/cli/Unix.zig index 716700a1852..dbcda59ff20 100644 --- a/src/cli/Unix.zig +++ b/src/cli/Unix.zig @@ -16,6 +16,8 @@ pub fn init() Error!Unix { var new_termios = old_termios; new_termios.lflag.ICANON = false; new_termios.lflag.ECHO = false; + new_termios.lflag.ISIG = false; + new_termios.lflag.IEXTEN = false; new_termios.cc[@intFromEnum(std.posix.V.INTR)] = 0; From f85334347ff96b16566788e375cd019198d7c918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Sat, 27 Jun 2026 00:07:58 +1200 Subject: [PATCH 4/6] Add simple-replay logic: after replaying command N, typing down selects command N+1 --- src/cli/ReplLine.zig | 192 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 9 deletions(-) diff --git a/src/cli/ReplLine.zig b/src/cli/ReplLine.zig index b42648286d7..52c42ce4dcb 100644 --- a/src/cli/ReplLine.zig +++ b/src/cli/ReplLine.zig @@ -225,12 +225,14 @@ const ReplLine = @This(); allocator: Allocator, history: History, kill_ring: ?[]const u8, +replay_index: ?usize, pub fn init(allocator: Allocator) ReplLine { return ReplLine{ .allocator = allocator, .history = History.init(allocator), .kill_ring = null, + .replay_index = null, }; } @@ -264,6 +266,8 @@ const LineState = struct { history_index: ?usize, transient_line: ?[]const u8, kill_ring: *?[]const u8, + last_navigated_index: ?usize, + replay_index: *?usize, /// Set after a Ctrl-C so that a second consecutive Ctrl-C quits. Any other /// input event clears it, so the two presses must be back-to-back. ctrl_c_armed: bool, @@ -501,6 +505,8 @@ fn historyBackward(state: *LineState) CommandError!void { try state.out.writeAll(state.line_buffer.items); try ansi_term.clearFromCursorToLineEnd(state.out); // Clear any ghost text try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); + + state.last_navigated_index = state.history_index; } fn historyForward(state: *LineState) CommandError!void { @@ -529,6 +535,8 @@ fn historyForward(state: *LineState) CommandError!void { try state.out.writeAll(state.line_buffer.items); try ansi_term.clearFromCursorToLineEnd(state.out); // Clear any ghost text try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); + + state.last_navigated_index = state.history_index; } fn findCommandFn(state: *LineState) CommandFn { @@ -674,6 +682,8 @@ fn helper(self: *ReplLine, outlive: Allocator, std_io: std.Io, prompt: []const u .history_index = null, .transient_line = null, .kill_ring = &self.kill_ring, + .last_navigated_index = null, + .replay_index = &self.replay_index, .ctrl_c_armed = false, }; @@ -781,16 +791,51 @@ fn helper(self: *ReplLine, outlive: Allocator, std_io: std.Io, prompt: []const u } const cmd = ReplLine.findCommandFn(&state); - cmd(&state) catch |err| { - switch (err) { - error.ExitRepl => return .eof, - error.NewLine => { - done = true; - break; - }, - else => |readline_error| return readline_error, + + var intercepted = false; + if (cmd == historyForward) { + if (state.history_index == null) { + if (state.replay_index.*) |r_idx| { + if (r_idx < state.history.entries.items.len) { + state.history_index = r_idx; + const entry = state.history.entries.items[r_idx]; + state.line_buffer.clearAndFree(state.temp); + try state.line_buffer.appendSlice(state.temp, entry); + state.col_offset = entry.len; + + try ansi_term.setCursorColumn(state.out, state.prompt_width); + try state.out.writeAll(state.line_buffer.items); + try ansi_term.clearFromCursorToLineEnd(state.out); + try ansi_term.setCursorColumn(state.out, state.prompt_width + state.col_offset); + + state.last_navigated_index = r_idx; + state.replay_index.* = null; + intercepted = true; + } else { + state.replay_index.* = null; + } + } } - }; + } else if (cmd == historyBackward) { + state.replay_index.* = null; + } else if (cmd == acceptLine) { + state.replay_index.* = if (state.last_navigated_index) |idx| idx + 1 else null; + } else { + state.replay_index.* = null; + } + + if (!intercepted) { + cmd(&state) catch |err| { + switch (err) { + error.ExitRepl => return .eof, + error.NewLine => { + done = true; + break; + }, + else => |readline_error| return readline_error, + } + }; + } } if (done) break; } @@ -1161,6 +1206,7 @@ test "Keyboard commands: advanced bindings" { var kill_ring: ?[]const u8 = null; defer if (kill_ring) |k| testing.allocator.free(k); + var dummy_replay_index: ?usize = null; var state = LineState{ .outlive = testing.allocator, .temp = testing.allocator, @@ -1176,6 +1222,8 @@ test "Keyboard commands: advanced bindings" { .history_index = null, .transient_line = null, .kill_ring = &kill_ring, + .last_navigated_index = null, + .replay_index = &dummy_replay_index, .ctrl_c_armed = false, }; defer state.line_buffer.deinit(testing.allocator); @@ -1313,6 +1361,7 @@ test "History: transient line preservation" { var kill_ring: ?[]const u8 = null; defer if (kill_ring) |k| testing.allocator.free(k); + var dummy_replay_index: ?usize = null; var state = LineState{ .outlive = testing.allocator, .temp = temp, @@ -1328,6 +1377,8 @@ test "History: transient line preservation" { .history_index = null, .transient_line = null, .kill_ring = &kill_ring, + .last_navigated_index = null, + .replay_index = &dummy_replay_index, .ctrl_c_armed = false, }; @@ -1356,3 +1407,126 @@ test "History: transient line preservation" { try testing.expectEqualStrings("third draft", state.line_buffer.items); try testing.expect(state.history_index == null); } + +test "History: replay index" { + var history = History.init(testing.allocator); + defer history.deinit(); + + try history.append("cmd 0"); + try history.append("cmd 1"); + try history.append("cmd 2"); + + var arena = base.SingleThreadArena.init(testing.allocator); + defer arena.deinit(); + const temp = arena.allocator(); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + + var kill_ring: ?[]const u8 = null; + defer if (kill_ring) |k| testing.allocator.free(k); + + var replay_index: ?usize = null; + var state = LineState{ + .outlive = testing.allocator, + .temp = temp, + .prompt = "> ", + .prompt_width = 2, + .out = &aw.writer, + .in = undefined, + .col_offset = 0, + .line_buffer = std.ArrayList(u8).empty, + .bytes_read = 0, + .in_buffer = undefined, + .history = &history, + .history_index = null, + .transient_line = null, + .kill_ring = &kill_ring, + .last_navigated_index = null, + .replay_index = &replay_index, + .ctrl_c_armed = false, + }; + defer state.line_buffer.deinit(temp); + + // Navigate backward twice + try historyBackward(&state); + try historyBackward(&state); + + try testing.expectEqual(@as(?usize, 1), state.history_index); + try testing.expectEqual(@as(?usize, 1), state.last_navigated_index); + + // Simulate line execution saving next replay index + replay_index = if (state.last_navigated_index) |idx| idx + 1 else null; + try testing.expectEqual(@as(?usize, 2), replay_index); + + // Initialize fresh state for the next prompt + var state2 = LineState{ + .outlive = testing.allocator, + .temp = temp, + .prompt = "> ", + .prompt_width = 2, + .out = &aw.writer, + .in = undefined, + .col_offset = 0, + .line_buffer = std.ArrayList(u8).empty, + .bytes_read = 0, + .in_buffer = undefined, + .history = &history, + .history_index = null, + .transient_line = null, + .kill_ring = &kill_ring, + .last_navigated_index = null, + .replay_index = &replay_index, + .ctrl_c_armed = false, + }; + defer state2.line_buffer.deinit(temp); + + // Simulate pressing DOWN on blank line (triggering intercept logic) + var intercepted = false; + if (state2.history_index == null) { + if (state2.replay_index.*) |r_idx| { + if (r_idx < state2.history.entries.items.len) { + state2.history_index = r_idx; + const entry = state2.history.entries.items[r_idx]; + state2.line_buffer.clearAndFree(state2.temp); + try state2.line_buffer.appendSlice(state2.temp, entry); + state2.col_offset = entry.len; + state2.last_navigated_index = r_idx; + state2.replay_index.* = null; + intercepted = true; + } + } + } + + try testing.expect(intercepted); + try testing.expectEqualStrings("cmd 2", state2.line_buffer.items); + try testing.expectEqual(@as(?usize, 2), state2.history_index); + try testing.expectEqual(@as(?usize, 2), state2.last_navigated_index); + try testing.expect(state2.replay_index.* == null); + + // Verify replay index is reset on new commands/input + var replay_index3: ?usize = 2; + var state3 = LineState{ + .outlive = testing.allocator, + .temp = temp, + .prompt = "> ", + .prompt_width = 2, + .out = &aw.writer, + .in = undefined, + .col_offset = 0, + .line_buffer = std.ArrayList(u8).empty, + .bytes_read = 0, + .in_buffer = undefined, + .history = &history, + .history_index = null, + .transient_line = null, + .kill_ring = &kill_ring, + .last_navigated_index = null, + .replay_index = &replay_index3, + .ctrl_c_armed = false, + }; + defer state3.line_buffer.deinit(temp); + + replay_index3 = null; + try testing.expect(state3.replay_index.* == null); +} From 78d6b72910fc2fe2afc9410a61d0c77c15cdfba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Sat, 27 Jun 2026 00:44:07 +1200 Subject: [PATCH 5/6] Support multiline commands --- src/cli/ReplLine.zig | 31 +++++++++++++++++++++++++------ src/cli/main.zig | 4 ++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/cli/ReplLine.zig b/src/cli/ReplLine.zig index 52c42ce4dcb..a04647c1d2b 100644 --- a/src/cli/ReplLine.zig +++ b/src/cli/ReplLine.zig @@ -209,13 +209,19 @@ const History = struct { } pub fn append(self: *History, input: []const u8) Allocator.Error!void { - if (self.entries.items.len > 0) { - const last = self.entries.items[self.entries.items.len - 1]; - if (std.mem.eql(u8, last, input)) return; + var it = std.mem.splitScalar(u8, input, '\n'); + while (it.next()) |raw_line| { + const line = std.mem.trimEnd(u8, raw_line, "\r"); + if (line.len == 0) continue; + + if (self.entries.items.len > 0) { + const last = self.entries.items[self.entries.items.len - 1]; + if (std.mem.eql(u8, last, line)) continue; + } + const line_copy = try self.allocator.alloc(u8, line.len); + @memcpy(line_copy, line); + try self.entries.append(self.allocator, line_copy); } - const input_copy = try self.allocator.alloc(u8, input.len); - @memcpy(input_copy, input); - try self.entries.append(self.allocator, input_copy); } }; @@ -1530,3 +1536,16 @@ test "History: replay index" { replay_index3 = null; try testing.expect(state3.replay_index.* == null); } + +test "History: multiline command split" { + var history = History.init(testing.allocator); + defer history.deinit(); + + try history.append("line A\nline B\r\nline C"); + + try testing.expectEqual(@as(usize, 3), history.entries.items.len); + try testing.expectEqualStrings("line A", history.entries.items[0]); + try testing.expectEqualStrings("line B", history.entries.items[1]); + try testing.expectEqualStrings("line C", history.entries.items[2]); +} + diff --git a/src/cli/main.zig b/src/cli/main.zig index 5fb2136962b..17a8d4f869d 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11244,8 +11244,9 @@ fn rocRepl(ctx: *CliCtx, repl_args: cli_args.ReplArgs) CliMainError!void { continue; } + try reader.history.append(raw_line); + if (pending.items.len == 0 and std.mem.findAny(u8, raw_line, "\n\r") != null) { - try reader.history.append(raw_line); should_exit = try processReplInput(ctx, &session, raw_line, report_config, &had_diagnostics); continue; } @@ -11256,7 +11257,6 @@ fn rocRepl(ctx: *CliCtx, repl_args: cli_args.ReplArgs) CliMainError!void { switch (try session.inputStatus(pending.items)) { .incomplete => {}, .complete, .invalid => { - try reader.history.append(pending.items); should_exit = try processReplInput(ctx, &session, pending.items, report_config, &had_diagnostics); pending.clearRetainingCapacity(); }, From 923fe9c5f57819d713c9e3c340602ffdc056cea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Geron?= Date: Sat, 27 Jun 2026 01:23:09 +1200 Subject: [PATCH 6/6] Flush ctx.io when pasting multiline code --- src/cli/main.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/main.zig b/src/cli/main.zig index 17a8d4f869d..a1662e5011e 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11248,6 +11248,7 @@ fn rocRepl(ctx: *CliCtx, repl_args: cli_args.ReplArgs) CliMainError!void { if (pending.items.len == 0 and std.mem.findAny(u8, raw_line, "\n\r") != null) { should_exit = try processReplInput(ctx, &session, raw_line, report_config, &had_diagnostics); + ctx.io.flush(); continue; }