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/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 }