From b424164922842796619cb6894ec46d729a8a6826 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 11 Aug 2026 18:30:13 -0300 Subject: clipboard, n/N and the tty prompt: three things that were half-wired Three changes that all turned out to be the same shape -- a feature that worked in one direction, or for one pane kind, and quietly did not in the others. CLIPBOARD. Every register write emitted set_clipboard, so deleting one character threw away whatever the desktop was holding; multi-cursor yank took the join's early return and emitted nothing at all, so the same key reached the clipboard on one cursor and not on two. Nothing could READ the clipboard: the SDL shell had no SDL_GetClipboardText anywhere in it, and the tty shell never asked for OSC 52, so `p` from another application was dead in both. Now it is helix's split. y/d/c/p/P/R and the acme chords are the DEFAULT REGISTER and nothing else; the system clipboard is five words on helix's own letters -- SPC y, SPC Y, SPC p, SPC P, SPC R -- spelled as builtins so they land in Help and are executable like every other verb. The one exception is the tag `y` chord, which still mirrors out because a tag is always insert, so SPC cannot be pressed there, and copying the path out is the whole point of the chord. Reading is a new read_clipboard effect answered by an ordinary Event.paste, so the round trip is honest about being one: SDL and NSPasteboard answer inside the same drain, the browser answers a promise, and a terminal answers over OSC 52 or -- far more often -- refuses. A refused read is a paste that does not happen, and the request dies at the next keystroke rather than landing minutes late in whatever pane is focused by then. The tty shell also enables BRACKETED PASTE now and coalesces paste_start..paste_end into one event. Before this a paste arrived as a flood of individual key presses: plausible in insert mode, and in normal mode every pasted character ran as a command. n/N. They stepped the armed results buffer and immediately Looked each row, so you could not walk past a hit without opening it. They are a MOTION now: select the next look-able text, open nothing, and let Enter decide. What they step is the largest whitespace-delimited run look.resolve can act on (look.lookableSpan, wrapper punctuation peeled), over a RING of panes -- every pane that has performed a look, most recent first, then the output buffers that have not, newest first, and only if both are empty the pane in front of you. N is the exact inverse of n, computed rather than remembered: both directions ask the same question about the same spans and compare against the column the walk parks on, so x presses one way and x back land exactly where you started, pane boundaries and the ring's seam included. A ring rather than a list with two ends because a shell's cursor sits at the prompt, below everything it has printed, so a walk that could not come round would have nowhere to go on the very first press -- which is the case n/N were written for. One motion everywhere, no pane-kind or buffer-kind special case. The only thing a buffer may change is the GRAIN of what a step selects, and it does it with one flag rather than a branch: output_pane.Traits.commands (renamed from `executes`, which named one reader's behaviour rather than the fact) makes a row select WHOLE, because a ThemeSel line is a word to run and has no path inside it to pick out. `]d`/`[d` are not n/N -- they are helix's diagnostic motions, their job is to ARRIVE, and they still reach searchStep. THE TTY PROMPT. Leaving raw tty blanked the prompt row, and the command you had typed at that prompt shares the row, so it went too -- a shell out of tty read as output only. OSC 133 marks the row CELL by cell, so the two are separable: config.tty_blank = .prompt cuts the prompt's own columns and leaves the command, left-hugged at column 0 in line with the output under it rather than in a bay of blanks. .prompt_and_input is the old behaviour, kept. Because the row is now something you can put a cursor in, enterTty adds the hidden prompt width back before asking ghostty to walk the shell's own cursor to it -- the modal column on a cut row is short by exactly that much. Verified: unit-test 186/186 (nine new), snap 87/87 (new ttyprompt.snap), hxdiff 481 and hxparity 561 with 0 mismatches, tty and gui both build. And against the real binaries rather than the harness: in a pty, SPC y emits OSC 52 carrying exactly the selection while plain y emits nothing, SPC p issues the read and pastes the reply, and a bracketed paste of "dd..." inserts text instead of deleting two lines. In a real SDL window, SPC y then SPC p round trips through the system clipboard while the default register holds different text. Setting tty_blank back to .prompt_and_input reproduces all 86 old goldens byte for byte. --- src/tty/tty.zig | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 2 deletions(-) (limited to 'src/tty/tty.zig') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index e8e3c1bd..eadc3a9d 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -37,6 +37,13 @@ pub const Command = struct { winsize: vaxis.Winsize, mouse: vaxis.Mouse, paste: []const u8, + /// The bracketed-paste brackets. vaxis posts them ONLY because this + /// union declares fields with these exact names — its Loop gates every + /// event on `@hasField` — and the pasted bytes themselves arrive + /// BETWEEN them as ordinary key presses, which the loop accumulates + /// into one `.paste` above instead of running as commands. + paste_start, + paste_end, /// a language query finished on a worker; rows are lsp-domain-owned lsp_done: struct { id: u32, rows: []u8 }, /// a selection-filter worker finished; every stdout is gpa-owned @@ -403,6 +410,15 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // gtk-enable-primary-paste=false — no mode we request can surface middle // clicks there (see test/snapshots/ghostty-mid.snap). try vx.setMouseMode(tty.writer(), true); + // Bracketed paste. Without it a paste into pardes-in-a-terminal is just a + // flood of key presses: plausible-looking in insert mode, and in normal + // mode every pasted character runs as a command. With it the terminal + // wraps the bytes in \x1b[200~ / \x1b[201~ and the loop coalesces them. + // No defer to switch it back off, for the same reason the mouse modes + // above have none: setBracketedPaste records state.bracketed_paste, and + // vaxis's resetState — reached from the `defer vx.deinit` above, while the + // tty is still open — sends the disable off that flag. + try vx.setBracketedPaste(tty.writer(), true); pardes.image.start(io, allocs.image); if (comptime pardes.pdf_enabled) pardes.pdf.start(allocs.pdf); @@ -569,6 +585,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; + // Where a bracketed paste is assembled. It has to outlive one drain pass: + // the burst arrives over as many passes as the terminal takes to write it, + // and the markers are the only thing that says where it ends. + var paste_buf: std.Io.Writer.Allocating = .init(gpa); + defer paste_buf.deinit(); + var in_paste = false; + // 4 MiB ceiling, past which the tail is dropped rather than grown into. A + // paste that large is a mis-click on a file, not an edit, and the core + // would have to hold the whole of it as one undo entry. + const max_paste_bytes: usize = 4 << 20; while (!core.quit) { var event = if (pending) |ev| blk: { pending = null; @@ -610,7 +636,31 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { } core.update(.{ .eof = .{ .pane = @intCast(e.id) } }); }, - .key_press => |key| core.update(.{ .key = .{ + .key_press => |key| if (in_paste) { + // Between the markers a key is DATA, never a command. Same + // two inputs as the dispatch below, so a pasted character + // is exactly the character the core would have been given. + const text = key.text orelse ""; + const cp = mapKey(effCp(key)); + const bytes: []const u8 = if (text.len > 0) + text + else if (cp == pardes.Key.tab) + "\t" + else if (cp == pardes.Key.enter or (key.mods.ctrl and cp == 'j')) + // vaxis gives control bytes no text at all: a line + // break inside a paste reaches the ground parser as a + // bare CR (-> Key.enter) or, from a terminal that does + // not translate them, a bare LF — which that parser + // reports as ctrl+j. Nothing in here is a real + // keypress, so both of them are just a newline. + "\n" + else + // arrows, F-keys, a stray escape: noise a paste has no + // business carrying, dropped rather than smuggled in. + ""; + const room = max_paste_bytes -| paste_buf.written().len; + paste_buf.writer.writeAll(bytes[0..@min(bytes.len, room)]) catch {}; + } else core.update(.{ .key = .{ .cp = mapKey(effCp(key)), .text = key.text orelse "", .ctrl = key.mods.ctrl, @@ -653,6 +703,18 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + .paste_start => { + in_paste = true; + paste_buf.clearRetainingCapacity(); + }, + .paste_end => { + in_paste = false; + // ONE event for the whole paste — the core borrows the + // bytes for the call, exactly like the OSC 52 arm above. + const pasted = paste_buf.written(); + if (pasted.len > 0) core.update(.{ .paste = pasted }); + paste_buf.clearRetainingCapacity(); + }, .command => |line| { core.update(.{ .command = line }); gpa.free(line); @@ -683,7 +745,12 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { }, } batch += 1; - if (stop or output or native_pdf_page_changed or batch >= 64) break; + // A paste in flight keeps draining WITHOUT rendering: a hundred + // thousand pasted characters are one edit, not a hundred thousand + // render-worthy events. That cannot spin — the drain still ends + // the moment the queue runs dry (tryEvent below) — so a terminal + // which sends paste_start and never paste_end costs one frame. + if (stop or output or native_pdf_page_changed or (!in_paste and batch >= 64)) break; event = (try loop.tryEvent()) orelse break; } tz_event.end(); @@ -983,6 +1050,20 @@ fn drainEffects( // mirror the core's yank register out via OSC 52 if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {}; }, + .read_clipboard => { + // ...and the other direction, OSC 52 read. The answer arrives on + // vaxis's reader thread as an ordinary `.paste` event and reaches + // the core through the same path an outer bracketed paste does — + // this request is the only wiring it needs. "The answer arrives" + // is the optimistic reading: a clipboard READ is an exfiltration + // primitive and terminals treat it as one (ghostty prompts by + // default, xterm ships it off, a multiplexer or ssh link may eat + // it), and a refusal looks exactly like silence. So the core's + // pending request is dropped by the next keystroke rather than + // pasting minutes late, and `SPC p` in a locked-down terminal + // honestly does nothing. + vx.requestSystemClipboard(tty.writer()) catch {}; + }, .lsp => |q| { if (!threads_ok) continue; // pre-loop drain: nothing to answer to yet const pane = core.panes[q.pane] orelse continue; -- cgit v1.3