From 505d15e238e4e9c5020a8e567d700bdbf67a8528 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 14:23:50 -0300 Subject: A paste into a terminal is xterm's paste: bracketed when the program set 2004, and the control bytes a paste could smuggle a command in by, ESC first, become spaces, so no ESC[201~ in the text ends the brackets early Every paste path goes through edit.typeToTty (the GUI's and the tty's paste, p from a register, the acme chord's paste, a REPL's send), which now takes ghostty's paste encoder. A 9P write to a terminal's body or pty/data stays typed input, not a paste. The GUI test shell's stdin now takes a bracketed paste as one paste, as the tty does, not as keys. Co-Authored-By: Claude Opus 5.5 --- src/edit.zig | 39 +++++++++++++++++++++++++++++---------- src/gui/gui.zig | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/terminal.zig | 15 +++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/edit.zig b/src/edit.zig index 98c60efb..1305d66f 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -111,19 +111,12 @@ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) p.emit(.read_clipboard); } +/// Text pasted, yanked or sent into the program at `id`, as xterm pastes +/// (terminal.encodePaste). pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; - if (panes.terminal.bracketedPaste(pane)) { - p.emitWrite(id, "\x1b[200~"); - p.emitWrite(id, text); - p.emitWrite(id, "\x1b[201~"); - return; - } const cp = p.scratch.allocator().dupe(u8, text) catch return; - for (cp) |*ch| { - if (ch.* == '\n') ch.* = '\r'; - } - p.emitWrite(id, cp); + for (panes.terminal.encodePaste(pane, cp)) |part| if (part.len > 0) p.emitWrite(id, part); } pub fn applyPaste(p: *Pardes, bytes: []const u8) void { @@ -2405,3 +2398,29 @@ test "a typed edit's known end is where the texts' comparison would find it" { try std.testing.expectEqual(by_scan.col, t.last_edit.?.col); } } + +test "a paste into a terminal is xterm's: bracketed when the program asked, control bytes spaces, so no ESC[201~ ends the brackets early" { + if (comptime !panes.terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true }); + defer p.deinit(); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + const take = struct { + fn f(pp: *Pardes, o: *std.ArrayList(u8)) ![]const u8 { + o.clearRetainingCapacity(); + while (pp.nextEffect()) |effect| { + if (effect == .write and effect.write.pane == 0) try o.appendSlice(pp.gpa, effect.write.bytes.slice()); + } + return o.items; + } + }.f; + _ = try take(p, &out); + try std.testing.expectEqual(.tty, p.panes[0].?.body.mode); + p.update(.{ .paste = "echo a\nb\x03\tc" }); + try std.testing.expectEqualStrings("echo a\rb \tc", try take(p, &out)); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + _ = try take(p, &out); + p.update(.{ .paste = "x\x1b[201~y\n" }); + try std.testing.expectEqualStrings("\x1b[200~x [201~y\n\x1b[201~", try take(p, &out)); +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 81b68afd..14205906 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3097,6 +3097,9 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const StdinFeed = struct { parser: vaxis.Parser = .{}, + /// Inside the driver's bracketed paste: its keys are the paste's text. + in_paste: bool = false, + paste: std.ArrayList(u8) = .empty, cache: vaxis.GraphemeCache = .{}, buf: [1024]u8 = undefined, fill: usize = 0, @@ -3241,10 +3244,24 @@ const StdinFeed = struct { seq_start += result.n; const event = result.event orelse continue; switch (event) { - .key_press => |key| { + .key_press => |key| if (f.in_paste) { + // A newline reads as Ctrl-J, as the tty's paste has it. + const text = key.text orelse if (key.codepoint == vaxis.Key.enter or (key.mods.ctrl and key.codepoint == 'j')) "\n" else if (key.codepoint == vaxis.Key.tab) "\t" else ""; + f.paste.appendSlice(gpa, text) catch {}; + } else { postKey(in, key, if (key.text) |t| f.cache.put(t) else ""); out.n_events += 1; }, + .paste_start => { + f.in_paste = true; + f.paste.clearRetainingCapacity(); + }, + .paste_end => { + f.in_paste = false; + if (f.paste.items.len > 0) in.post(.{ .paste = f.paste.items }); + f.paste.clearAndFree(gpa); + out.n_events += 1; + }, .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { .left => .left, @@ -4820,6 +4837,39 @@ test "the headless grid host round-trips a yank back as a paste" { try std.testing.expectEqual(before + yanked.len, pane.file.?.content.len); } +test "the clipboard pasted into a terminal reaches its pty as xterm pastes: bracketed when asked, no ESC[201~ smuggled through" { + if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true }); + defer core.deinit(); + var shell: Shell = undefined; + shell.core = core; + var sent: std.ArrayList(u8) = .empty; + defer sent.deinit(gpa); + const take = struct { + fn f(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 { + out.clearRetainingCapacity(); + while (core_.nextEffect()) |effect| { + if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice()); + } + return out.items; + } + }.f; + _ = try take(core, &sent); + // The clipboard is SDL's own with no display to hold one. + _ = c.SDL_SetHint(c.SDL_HINT_VIDEO_DRIVER, "dummy"); + defer _ = c.SDL_ResetHint(c.SDL_HINT_VIDEO_DRIVER); + if (!c.SDL_InitSubSystem(c.SDL_INIT_VIDEO)) return error.SkipZigTest; + defer c.SDL_QuitSubSystem(c.SDL_INIT_VIDEO); + putClipboard(gpa, "ls\x1b[201~; rm -rf ~\nx"); + readClipboard(&shell); + try std.testing.expectEqualStrings("ls [201~; rm -rf ~\rx", try take(core, &sent)); + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + _ = try take(core, &sent); + readClipboard(&shell); + try std.testing.expectEqualStrings("\x1b[200~ls [201~; rm -rf ~\nx\x1b[201~", try take(core, &sent)); +} + fn openLink(ctx: ?*anyopaque, url: []const u8) void { _ = ctx; look.openLink(url); // desktop browser diff --git a/src/terminal.zig b/src/terminal.zig index af540202..4cdfa52d 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -380,6 +380,21 @@ pub fn reportsMouse(pane: *const Pane) bool { return state.vt.flags.mouse_event != .none; } +/// A paste as xterm makes one (ghostty's encoder): the control bytes a +/// paste could smuggle a command in by (ESC, so no ESC[201~ can end +/// 2004's brackets early, NUL, ^C and the rest of xterm's list) become +/// spaces, then the text goes inside the brackets when the program set +/// 2004, else with its newlines as Returns. `text` is rewritten in place. +pub fn encodePaste(pane: *const Pane, text: []u8) [3][]const u8 { + if (comptime !enabled) { + for (text) |*ch| if (ch.* == '\n') { + ch.* = '\r'; + }; + return .{ "", text, "" }; + } + return ghostty_vt.input.encodePaste(text, .{ .bracketed = bracketedPaste(pane) }); +} + /// DECSET 1004: the program wants to hear when it gains and loses focus. pub fn reportsFocus(pane: *const Pane) bool { if (comptime !enabled) return false; -- cgit v1.3