also looking at this
feat(line-editor): support bracketed paste mode
#55
2 files
+108
-14
| @@ -14,6 +14,8 @@ pub const EscapeSequence = enum { | ||
| 14 | 14 | delete, |
| 15 | 15 | page_up, |
| 16 | 16 | page_down, |
| 17 | paste_start, // ESC[200~ (bracketed paste begin) | |
| 18 | paste_end, // ESC[201~ (bracketed paste end) | |
| 17 | 19 | unknown, |
| 18 | 20 | |
| 19 | 21 | /// Parse escape sequence from input |
| @@ -36,23 +38,42 @@ pub const EscapeSequence = enum { | ||
| 36 | 38 | else => {}, |
| 37 | 39 | } |
| 38 | 40 | |
| 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 | }; | |
| 46 | 64 | } |
| 47 | } | |
| 48 | 65 | |
| 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 | }; | |
| 55 | 74 | } |
| 75 | ||
| 76 | return .unknown; | |
| 56 | 77 | } |
| 57 | 78 | } |
| 58 | 79 | |
| @@ -500,6 +500,13 @@ pub const LineEditor = struct { | ||
| 500 | 500 | try self.terminal.enableRawMode(); |
| 501 | 501 | errdefer self.terminal.disableRawMode() catch {}; |
| 502 | 502 | |
| 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 | ||
| 503 | 510 | // Reset state |
| 504 | 511 | self.cursor = 0; |
| 505 | 512 | self.length = 0; |
| @@ -1269,6 +1276,70 @@ pub const LineEditor = struct { | ||
| 1269 | 1276 | } |
| 1270 | 1277 | } |
| 1271 | 1278 | |
| 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 | ||
| 1272 | 1343 | fn deleteChar(self: *LineEditor) !void { |
| 1273 | 1344 | if (self.cursor >= self.length) return; |
| 1274 | 1345 | |
| @@ -1720,6 +1791,8 @@ pub const LineEditor = struct { | ||
| 1720 | 1791 | } |
| 1721 | 1792 | }, |
| 1722 | 1793 | .delete => try self.deleteChar(), |
| 1794 | .paste_start => try self.handlePaste(), | |
| 1795 | .paste_end => {}, // stray paste-end without a start: ignore | |
| 1723 | 1796 | else => {}, |
| 1724 | 1797 | } |
| 1725 | 1798 | } |