ReviewOS

also looking at this

den-shell/den

feat(line-editor): support bracketed paste mode

#55
Merged chris wants to merge feat/bracketed-paste into main
2 files +108 -14
src/utils/terminal/escape.zigmodified+35-14
Changes to src/utils/terminal/escape.zig
@@ -14,6 +14,8 @@ pub const EscapeSequence = enum {
1414 delete,
1515 page_up,
1616 page_down,
17 paste_start, // ESC[200~ (bracketed paste begin)
18 paste_end, // ESC[201~ (bracketed paste end)
1719 unknown,
1820
1921 /// Parse escape sequence from input
@@ -36,23 +38,42 @@ pub const EscapeSequence = enum {
3638 else => {},
3739 }
3840
39 // Multi-character sequences
40 if (bytes.len >= 4 and bytes[3] == '~') {
41 switch (bytes[2]) {
42 '3' => return .delete,
43 '5' => return .page_up,
44 '6' => return .page_down,
45 else => {},
41 // Numeric-parameter sequences: ESC[<digits>~ (delete, paste, ...) and
42 // ESC[1;5C/D (ctrl arrows). bytes[2] is the first parameter digit.
43 // Return null while the parameter is still arriving so multi-digit
44 // codes like 200/201 (bracketed paste) are never truncated to .unknown.
45 if (bytes[2] >= '0' and bytes[2] <= '9') {
46 var idx: usize = 2;
47 while (idx < bytes.len and bytes[idx] >= '0' and bytes[idx] <= '9') : (idx += 1) {}
48 if (idx >= bytes.len) return null; // still reading digits
49
50 // ESC[<num>~
51 if (bytes[idx] == '~') {
52 var num: u32 = 0;
53 for (bytes[2..idx]) |d| num = num * 10 + (d - '0');
54 return switch (num) {
55 1 => .home,
56 3 => .delete,
57 4 => .end_key,
58 5 => .page_up,
59 6 => .page_down,
60 200 => .paste_start,
61 201 => .paste_end,
62 else => .unknown,
63 };
4664 }
47 }
4865
49 // Ctrl+Arrow sequences: ESC[1;5C (right) or ESC[1;5D (left)
50 if (bytes.len >= 6 and bytes[2] == '1' and bytes[3] == ';' and bytes[4] == '5') {
51 switch (bytes[5]) {
52 'C' => return .ctrl_right,
53 'D' => return .ctrl_left,
54 else => {},
66 // ESC[1;5C / ESC[1;5D (ctrl arrows)
67 if (bytes[idx] == ';') {
68 if (bytes.len < idx + 3) return null; // need ";5C"/";5D"
69 return switch (bytes[idx + 2]) {
70 'C' => .ctrl_right,
71 'D' => .ctrl_left,
72 else => .unknown,
73 };
5574 }
75
76 return .unknown;
5677 }
5778 }
5879
src/utils/terminal/line_editor.zigmodified+73-0
Changes to src/utils/terminal/line_editor.zig
@@ -500,6 +500,13 @@ pub const LineEditor = struct {
500500 try self.terminal.enableRawMode();
501501 errdefer self.terminal.disableRawMode() catch {};
502502
503 // Enable bracketed paste so the terminal wraps pasted text in
504 // ESC[200~ ... ESC[201~. The defer disables it on every exit path so we
505 // never leave the user's terminal stuck in bracketed-paste mode after
506 // Den hands control back. See handlePaste for how the text is consumed.
507 self.writeBytes("\x1B[?2004h") catch {};
508 defer self.writeBytes("\x1B[?2004l") catch {};
509
503510 // Reset state
504511 self.cursor = 0;
505512 self.length = 0;
@@ -1269,6 +1276,70 @@ pub const LineEditor = struct {
12691276 }
12701277 }
12711278
1279 /// Consume a bracketed paste: after ESC[200~ we read raw bytes until the
1280 /// ESC[201~ terminator and insert them as literal text. Embedded newlines are
1281 /// inserted (normalized to spaces) rather than submitting the line, so
1282 /// pasting a multi-line block never auto-executes — the footgun this prevents.
1283 fn handlePaste(self: *LineEditor) !void {
1284 self.saveUndoState();
1285 self.clearHistorySearch();
1286
1287 // Terminator state machine for ESC [ 2 0 1 ~
1288 const term = [_]u8{ 0x1B, '[', '2', '0', '1', '~' };
1289 var matched: usize = 0;
1290
1291 while (true) {
1292 const byte = (try self.terminal.readByte()) orelse {
1293 // No byte ready yet — wait briefly; a paste arrives as a burst,
1294 // so this only spins at the very end.
1295 std.Io.sleep(std.Options.debug_io, std.Io.Duration.fromNanoseconds(@as(i96, 1_000_000)), .awake) catch {};
1296 continue;
1297 };
1298
1299 // Track progress toward the paste-end terminator.
1300 if (byte == term[matched]) {
1301 matched += 1;
1302 if (matched == term.len) break; // full terminator seen
1303 continue;
1304 }
1305
1306 // Mismatch: bytes tentatively consumed as a partial terminator were
1307 // actually pasted content — flush them, then reconsider this byte.
1308 if (matched > 0) {
1309 for (term[0..matched]) |pending| {
1310 self.insertPasteByte(pending);
1311 }
1312 matched = 0;
1313 if (byte == term[0]) {
1314 matched = 1;
1315 continue;
1316 }
1317 }
1318
1319 self.insertPasteByte(byte);
1320 }
1321
1322 try self.redrawLine();
1323 }
1324
1325 /// Insert one pasted byte WITHOUT redrawing (handlePaste redraws once at the
1326 /// end). Newline/CR/tab become a space so the paste stays on one editable
1327 /// line and cannot submit itself.
1328 fn insertPasteByte(self: *LineEditor, byte: u8) void {
1329 if (self.length >= self.buffer.len) return;
1330 const ch: u8 = switch (byte) {
1331 '\n', '\r', '\t' => ' ',
1332 else => byte,
1333 };
1334 var i: usize = self.length;
1335 while (i > self.cursor) : (i -= 1) {
1336 self.buffer[i] = self.buffer[i - 1];
1337 }
1338 self.buffer[self.cursor] = ch;
1339 self.cursor += 1;
1340 self.length += 1;
1341 }
1342
12721343 fn deleteChar(self: *LineEditor) !void {
12731344 if (self.cursor >= self.length) return;
12741345
@@ -1720,6 +1791,8 @@ pub const LineEditor = struct {
17201791 }
17211792 },
17221793 .delete => try self.deleteChar(),
1794 .paste_start => try self.handlePaste(),
1795 .paste_end => {}, // stray paste-end without a start: ignore
17231796 else => {},
17241797 }
17251798 }