Add opt-in paste hooks: PasteInterceptor and PasteBurstHook - #1132
Add opt-in paste hooks: PasteInterceptor and PasteBurstHook#1132easyinplay wants to merge 6 commits into
Conversation
ae3857f to
1a1c3ba
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces two opt-in host extension points that let applications customize how reedline handles paste input: one for intercepting Ctrl+V OS-clipboard pastes, and another for timing-based “paste burst” coalescing on terminals that don’t emit bracketed paste events.
Changes:
- Add
PasteInterceptor+PasteActionand wireEditCommand::PasteSystemthrough the interceptor when installed. - Add
PasteBurstHookand extend the read loop/batch processing to optionally coalesce rapid key-event streams into a single insertion and reclassify paste-embeddedEnteras newline. - Add tests covering the opt-in defaults and the new hook-driven paths.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/paste_interceptor.rs | New public trait + enum for intercepting PasteSystem and optionally expanding placeholders for transcript display. |
| src/paste_burst_hook.rs | New public timing-hook trait for host-driven burst detection/coalescing. |
| src/lib.rs | Exports the new public API types. |
| src/engine.rs | Stores the hooks, adds builder methods, integrates interception + burst logic into the read loop, and adds tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Event::Key(KeyEvent { | ||
| code: KeyCode::Enter, | ||
| modifiers: KeyModifiers::NONE, | ||
| kind: KeyEventKind::Press, | ||
| .. | ||
| }) => { |
There was a problem hiding this comment.
Good catch. The coalescing path now consults enter_is_newline() for each Enter instead of assuming they are all embedded. When the hook judges an Enter to be a real submit (one drained into the same batch right after the paste), the burst ends: the coalesced text is inserted and the line submits via a validator-gated ReedlineEvent::Enter, so a quick submit after a paste is no longer swallowed. Embedded Enters that the hook flags as newlines still coalesce as before. Added a test for the paste-then-submit case.
| if let Some(hook) = self.paste_burst.clone() { | ||
| if !events.is_empty() | ||
| && self.editor.line_buffer().get_buffer().is_empty() | ||
| && events.iter().all(|e| { | ||
| matches!( | ||
| e, | ||
| Event::Key(KeyEvent { | ||
| code: KeyCode::Enter, | ||
| modifiers: KeyModifiers::NONE, | ||
| .. | ||
| }) | ||
| ) | ||
| }) | ||
| && event::poll(hook.poll_timeout())? | ||
| { | ||
| continue; | ||
| } | ||
| } |
There was a problem hiding this comment.
Done. The leading-Enter drop now matches only KeyEventKind::Press (release events are ignored rather than breaking the all-bare-Enter test), and it asks enter_is_newline() before dropping. An intentional empty submission followed by typing the next command within the poll window is no longer dropped, since the hook reports that Enter is not paste-embedded. Consulting the hook first also short-circuits the poll for the normal empty-line Enter, removing that latency from the common path.
| /// True while a real paste burst is coalescing — the read loop keeps | ||
| /// draining the event queue instead of processing the batch. | ||
| fn is_burst_active(&self) -> bool; | ||
|
|
||
| /// Poll timeout to use while draining an active burst (the idle-flush | ||
| /// window). When a poll of this duration finds no new event, the burst has | ||
| /// settled. | ||
| fn poll_timeout(&self) -> Duration; | ||
|
|
||
| /// Reset detector state after a batch settles, so the next line starts | ||
| /// clean. | ||
| fn settle(&self); |
There was a problem hiding this comment.
I went with documenting the contract. is_burst_active() now specifies that an implementation must latch true from burst detection until settle() is called, because the engine queries it twice (once to keep draining, once after the idle flush to decide whether to coalesce the batch) and both must agree; returning false once idle would skip coalescing and leak the raw paste text. settle()'s doc notes it is the latch-release point. I kept the engine as-is rather than threading the captured flag through process_input_batch, since the method has several internal call sites and the documented contract makes the existing double-query correct.
|
Sounds very cool. I've asked copilot to review it. |
|
Thoughts @kronberger-droid ? |
|
Seems reasonable. The burst hook I can test out, but for the paste interceptor it would be nice to have an example. Only if this is reasonable. @easyinplay |
|
Ya, tests and/or examples that you can run with |
|
Pushed one more commit tightening the |
|
Very cool. I have another question. I'm wondering if the traits should have something like an |
|
Good question. Two parts to it. How Ctrl-C is caught: it isn't touched by this PR at all — that's why it's hard to spot in the diff. A Ctrl-C still goes through reedline's existing Whether a cleanup hook is needed: for correctness, no, and the reason is already in the read loop. After each batch is processed, let batch_result = self.process_input_batch(prompt, events)?;
if let Some(hook) = &self.paste_burst {
hook.settle();
}
if let ControlFlow::Break(signal) = batch_result {
return Ok(signal);
}A Ctrl-C is a The only thing an |
|
ok, i'm fine with it the way it is. Thanks for the explanation. I'm good with this PR. @kronberger-droid are you good? |
|
Hey @easyinplay, @fdncred One thing that came up during the review with Claude is in the burst contract:
Two smaller things:
Fine to land from my side once the burst question settles and those are in. |
|
On the comment density: that's a deliberate habit of mine. Half of it is for my future self — when I come back to review a change months later, the code itself is easy enough to re-read, but the intent behind it and the details that drove it are what fade first, so I write them down while I still have them. The other half is that this is a PR: reasonable comments should let reviewers get to the intent faster than reverse-engineering it from the diff. You're right about the burst contract, and it goes deeper than the test: for a hook that follows the doc, the Looking at why the branch felt necessary in the first place made the real answer obvious: it never was. Any The old test is replaced by
|
|
@easyinplay Here is a fix for the bug of crossterm not supporting bracketed paste mode on Windows. However, there is a lack of reviewers and it is unlikely to be merged. Thank you. |
|
@eitsupi Thanks for the pointer — that PR is the fix I was hoping already existed when I opened #1127. Let me be plain about where I think the two pieces sit, because I don't want this PR to read as competing with yours: the root fix belongs in crossterm. A host-side timing oracle is a worse instrument than a real What I don't think follows is that reedline then needs nothing. Three gaps survive #1030, and the first is from your own PR description:
So: crossterm#1030 as the primary path, and the burst hook as the fallback for what the primary path structurally can't cover. It's opt-in and no-op when absent, so a host that knows it's on a VT-capable Windows Terminal simply doesn't install it. On reviewing it — one practical note first: the branch is currently conflicting with master, and master has moved since (a few merges landed on 2026-08-08). A rebase would help on its own, quite apart from anything I do: right now anyone who tries to evaluate it is evaluating something other than what would actually merge, which is a real disincentive for a reviewer looking at a diff this size. I'd also be more use to you empirically than line-by-line — I can't credibly review the Windows console input internals, but I do have a Windows bench across Windows Terminal, conhost, WezTerm nightly, Warp, Alacritty and WaveTerm, driving a real reedline-based application, and I've spent a lot of time on exactly the failure mode you're fixing. If it gets rebased and I find a window, I'd be interested in running that matrix and reporting what each terminal actually does. I can't promise when, so please don't plan around it. @fdncred @kronberger-droid — separately, on this PR: the 2026-07-26 commit addressed everything from the burst-contract thread. The dead |
|
@easyinplay Thank you. I've updated crossterm-rs/crossterm#1030 I hope someone reviews this and it gets merged into crossterm. |
1ab1788 to
d1469c5
Compare
|
Rebased onto main and force-pushed — mergeable again. The only conflict was in Since #1144 touches the read loop as well, I went through the merged loop by hand rather than trusting a clean textual merge. Locally: @fdncred @kronberger-droid — restating the question from my last comment now that it's rebased: anything further you'd like changed, or is this good to land? |
|
In principle I think its good to land, but a release is comming up this weekend. I would park this for right after the release since I want enough time for us to test it. |
|
I went and measured crossterm-rs/crossterm#1030 instead of guessing about it, and one of the three gaps I listed above was wrong. Correcting it here rather than leaving it standing. What I got wrong. I wrote that on Warp "the paste never reaches us as a paste at all… with no bracketed-paste markers anywhere in the stream." The second half is false. Warp does emit the markers. What actually happened in our case is that stock crossterm on Windows never enables So #1030 covers more ground than I credited it with — not just Windows Terminal, but Warp too. The category the example was meant to illustrate does survive, with a real instance. OxideTerm 2.0.19 delivered a 198-char paste as 394 individual key events on #1030's branch, inter-event gaps of 150–400 µs. crossterm's parser can only decode markers a terminal actually sends, so a correct parser doesn't recover this one. (That same terminal produced a clean Gap 1 (legacy consoles, which #1030's own scope table lists as "Not delivered (unchanged)") and gap 3 (consumers pinned to an older crossterm) are unaffected. The shape of the argument is the same and I'd still put it the same way — crossterm#1030 as the primary path, this hook as an opt-in fallback for what that path structurally cannot cover — but it stands on a terminal I measured rather than one I mis-remembered. One more thing that came out of the measurements and matters to this PR directly: on every terminal that delivered a real |
Two small, opt-in host hooks for customizing paste handling. Both are no-ops when not installed, so existing behavior is byte-for-byte unchanged. PasteInterceptor: on a bare Ctrl+V (EditCommand::PasteSystem), a host can intercept the paste via on_paste() — e.g. collapse a large or image paste into a compact reference token while stashing the real content elsewhere — and optionally expand placeholders back for the submitted-line render via expand_for_display(). The interceptor reads the clipboard itself, so it can reach image bytes the text-only PasteSystem path never exposes. PasteBurstHook: a timing-based fallback for terminals that deliver a paste as a rapid stream of individual key events rather than a bracketed Event::Paste (notably Warp and Windows ConPTY, crossterm#737). Chars keep echoing live; the read loop feeds each just-read char to the host detector at read time (preserving inter-char timing), keeps draining while a burst is active, reclassifies a paste-embedded Enter as a newline, and coalesces the burst into a single insertion the host may reference-ify. Both traits are host-agnostic and held behind an Arc. Installed via Reedline::with_paste_interceptor / with_paste_burst; absent, the read loop and paste path behave exactly as before. Implements the two hooks proposed in nushell#1127. Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
Address three review points on the paste hooks: - In the burst-coalescing path, consult PasteBurstHook::enter_is_newline() for each Enter instead of treating every Enter as an embedded newline. A real submit Enter pressed right after a paste can be drained into the same batch; when the oracle judges an Enter to be a real submit, end the burst and submit the line (validator-gated) rather than swallowing it. - When dropping a lone leading Enter on an empty buffer, ask the hook whether the Enter is paste-embedded before dropping it, and match only KeyEventKind::Press so kitty keyboard release artifacts do not affect the heuristic. This keeps an intentional empty submission from being swallowed when the next command is typed within the poll window, and spares the normal empty-line Enter the poll latency. - Document the required latch semantics of is_burst_active(): it must stay true from burst detection until settle() is called, since the engine queries it again after the idle flush to decide whether to coalesce the batch. Add tests covering a paste followed by a real submit Enter and a multi-line burst that coalesces without submitting. Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
- examples/paste_interceptor.rs installs a PasteInterceptor that reads the clipboard itself, replaces a paste of three or more lines with a compact "[Pasted text nushell#1 +5 lines]" placeholder while the line is composed, and expands the placeholder back to the full text on submit through expand_for_display. It needs the system_clipboard feature, since EditCommand::PasteSystem only exists there, so it gets a [[example]] entry with required-features. The example also binds PasteSystem to Ctrl+V rather than relying on the default. The only default binding is Ctrl+Shift+V, which most terminals claim as their own paste shortcut and turn into injected key events, so the binding is never reached; a host installing an interceptor generally has to pick a key the terminal leaves alone. - examples/paste_burst.rs installs a PasteBurstHook backed by a deliberately naive timing detector: characters arriving closer together than a human can type declare a burst, so newlines inside a paste are inserted instead of submitting the line, while an Enter the user presses still submits. This is the path a terminal-injected paste takes, including the Ctrl+Shift+V one above. Once a burst is declared, enter_is_newline answers from that state and not from the clock, since the engine asks while parsing a batch it stopped draining because poll_timeout found the input idle: the newest pasted character is older than the idle window by then, so a freshness test would classify every burst newline as a submit. Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
The doc said the hook decides whether an Enter "arriving now" is paste-embedded, which reads as if the arrival timing were available at that point. It is not: the hook is never told when the Enter arrived, and for a detected burst the engine only stops draining once a poll of poll_timeout finds the input idle, so by the time the batch is parsed the newest pasted char is older than that window. An implementation that answers with a freshness test therefore returns false for every burst, whatever the threshold, and the paste submits at its first embedded newline. Spell out the call site and require that a detected burst be answered from the same latched state is_burst_active reports, with the reason a real submit is still not swallowed: an Enter the user presses lands in the next batch, after settle has released the latch. Also note that the timing test does hold on the other path, where a short paste that never reaches the burst threshold is not drained. Narrow the trait-level "read the clock itself" note to on_char, which is the only method called at the moment the input it describes arrives. Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
…anch A detected burst latches enter_is_newline's contract to always answer true, so the submit_after_burst branch that consulted it per-Enter and pushed ReedlineEvent::Enter could never actually fire. Every Enter drained into a burst batch arrives inside the poll-timeout idle window that keeps the burst coalescing, so it is always a paste-embedded newline; a real submit Enter lands past that window in the next batch and is handled there normally. Push '\n' unconditionally instead and remove the unreachable branch. Update the trait docs on enter_is_newline to match: it is now only consulted outside a detected burst (the short-paste path), where the question still follows the chars immediately with no idle poll in between, so the freshness-test guidance stays correct there. Also: - add a test asserting resolve_burst's Some(..) placeholder is what lands in the buffer, not the raw coalesced text - add system_clipboard to the CI feature matrix so the two paste interceptor tests behind that feature actually run Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
Rebasing onto 0.51 raised the MSRV to 1.95, where clippy::unnecessary_map_or fires on `map_or(false, ..)` over an Option. CI runs clippy with -D warnings, so both sites were build failures on the new base. The surrounding engine code already reads the same way. Signed-off-by: easyinplay <4202001+easyinplay@users.noreply.github.com>
d1469c5 to
92eee4b
Compare



Summary
Two small, opt-in host hooks for customizing how reedline handles pastes, as proposed in #1127. Both are no-ops when not installed, so existing users see zero behavior change.
PasteInterceptor— on a bareCtrl+V(EditCommand::PasteSystem,system_clipboardfeature), a host can intervene instead of the default clipboard-read-and-insert:on_paste()returns aPasteAction(InsertText/Noop), and an optionalexpand_for_display()lets the host expand placeholders back for the submitted-line render. The interceptor reads the clipboard itself, so it can reach image bytes the text-only path never exposes. Installed viaReedline::with_paste_interceptor.PasteBurstHook— a timing-based fallback for terminals that deliver a paste as a rapid stream of individual key events instead of a bracketedEvent::Paste(notably Warp and Windows ConPTY, crossterm#737). Chars keep echoing live; the read loop feeds each just-read char to the host detector at read time (preserving inter-char timing), keeps draining whileis_burst_active(), reclassifies a paste-embeddedEnteras a newline viaenter_is_newline(), and coalesces the burst into a single insertion the host may reference-ify viaresolve_burst(). All detector state and thresholds live host-side. Installed viaReedline::with_paste_burst.Both traits are host-agnostic (no application-specific concepts),
Send + Sync, and held behind anArc.Public API additions:
PasteInterceptor,PasteAction,PasteBurstHook,Reedline::with_paste_interceptor,Reedline::with_paste_burst. No changes to existing API or behavior.Before
A bare
Ctrl+Valways reads the OS clipboard and inserts the raw text verbatim. On terminals without bracketed paste (Warp, Windows ConPTY), a multi-line paste that arrives as individual key events can submit prematurely on an embedded newline, since the read loop can't distinguish a paste-embeddedEnterfrom a settling submit by content alone.After
When a
PasteInterceptoris installed,Ctrl+Vroutes through it. When aPasteBurstHookis installed, the read loop uses the host's timing oracle to hold the burst together and treat embedded newlines as newlines. With neither installed, the read loop and paste path are byte-for-byte unchanged.Additional notes
PasteBurstHookgates the whole burst path on an installed hook and a detected burst; the no-hook path preserves the original per-event parse exactly.Enterstill submits, no interception) and that installing each hook drives the new path.cargo fmt --all,cargo clippy --locked --all-targets --all-features(clean), andcargo test --all --all-features(green).PasteInterceptorhalf first, or to discuss whether the timing hook belongs here vs. at the crossterm level (crossterm#737).closes #1127