Skip to content

Add opt-in paste hooks: PasteInterceptor and PasteBurstHook - #1132

Open
easyinplay wants to merge 6 commits into
nushell:mainfrom
easyinplay:feat/paste-hooks
Open

Add opt-in paste hooks: PasteInterceptor and PasteBurstHook#1132
easyinplay wants to merge 6 commits into
nushell:mainfrom
easyinplay:feat/paste-hooks

Conversation

@easyinplay

Copy link
Copy Markdown
Contributor

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 bare Ctrl+V (EditCommand::PasteSystem, system_clipboard feature), a host can intervene instead of the default clipboard-read-and-insert: on_paste() returns a PasteAction (InsertText/Noop), and an optional expand_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 via Reedline::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 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 is_burst_active(), reclassifies a paste-embedded Enter as a newline via enter_is_newline(), and coalesces the burst into a single insertion the host may reference-ify via resolve_burst(). All detector state and thresholds live host-side. Installed via Reedline::with_paste_burst.

Both traits are host-agnostic (no application-specific concepts), Send + Sync, and held behind an Arc.

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+V always 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-embedded Enter from a settling submit by content alone.

After

When a PasteInterceptor is installed, Ctrl+V routes through it. When a PasteBurstHook is 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

  • Both hooks are opt-in and no-op when absent. The read-loop change for PasteBurstHook gates the whole burst path on an installed hook and a detected burst; the no-hook path preserves the original per-event parse exactly.
  • Added tests cover the opt-in default (both hooks absent → bare Enter still submits, no interception) and that installing each hook drives the new path.
  • Ran cargo fmt --all, cargo clippy --locked --all-targets --all-features (clean), and cargo test --all --all-features (green).
  • Implement EOF #2 is the deeper of the two (it touches the read loop). Happy to split this into two PRs if you'd rather land the PasteInterceptor half first, or to discuss whether the timing hook belongs here vs. at the crossterm level (crossterm#737).

closes #1127

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + PasteAction and wire EditCommand::PasteSystem through the interceptor when installed.
  • Add PasteBurstHook and extend the read loop/batch processing to optionally coalesce rapid key-event streams into a single insertion and reclassify paste-embedded Enter as 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.

Comment thread src/engine.rs
Comment on lines +1224 to +1229
Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
}) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/engine.rs
Comment on lines +1144 to 1161
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;
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/paste_burst_hook.rs
Comment on lines +49 to +60
/// 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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fdncred

fdncred commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Sounds very cool. I've asked copilot to review it.

@fdncred

fdncred commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Thoughts @kronberger-droid ?

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Seems reasonable.
I will read the implementation a little and let claude run over it.
Just from a glance it looks clean.

The burst hook I can test out, but for the paste interceptor it would be nice to have an example.
Actually for both, but the burst is not much extra work.
You agree @fdncred?

Only if this is reasonable. @easyinplay
If its to much work its no problem, but I would really like to see the replace capability you mentioned in action.

@fdncred

fdncred commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Ya, tests and/or examples that you can run with cargo run --example paste_interceptor and paste_burst would be a great add. Examples are great because they show would-be-authors how to implement all of reedline's features. Glad you remembered it @kronberger-droid. Thoughts @easyinplay ?

@easyinplay

easyinplay commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Added the two examples:

  • cargo run --example paste_interceptor --features system_clipboard
  • cargo run --example paste_burst

paste_interceptor is the one showing the replace capability. on_paste reads the clipboard itself (reedline no longer does that once an interceptor is installed), and for a paste of three or more lines it stashes the full text and returns a compact [Pasted text #1 +5 lines] placeholder, so a large paste does not flood the composer. On submit, expand_for_display swaps the placeholder back for the full text, so the submitted line and the buffer returned in Signal::Success both carry the real content. Shorter pastes are inserted verbatim.

One thing worth flagging, because it caught me while testing the example: the default keybindings only bind PasteSystem to Ctrl+Shift+V, and that is the paste shortcut of most terminals. The terminal swallows it and injects the clipboard as ordinary key events, so the binding never fires — on Windows the multi-line paste then submits one line per line. Plain Ctrl+V has no binding in emacs mode, so out of the box there is no reachable way to trigger PasteSystem in those terminals at all. The example therefore binds Ctrl+V to PasteSystem itself and explains why, since any host installing an interceptor will run into the same thing. Happy to leave that as example-level documentation, but if you would rather change the default binding I can open a separate issue.

paste_burst covers the other half of that story: the terminal-injected paste. It installs a hook with a deliberately naive timing detector — characters arriving less than 10 ms apart count as machine-fast, six in a row declare a burst, and the burst flag stays latched until settle. Newlines inside the paste get inserted instead of submitting the line, while an Enter you press yourself still submits, since it arrives in a batch of its own after the burst settled.

Writing it surfaced a constraint that is worth spelling out for anyone implementing the trait, so the example documents it: once a burst is declared, enter_is_newline must answer from that latched state rather than from the clock. The engine asks the question while parsing a batch it stopped draining because poll_timeout reported the input idle, so by then the newest pasted character is necessarily older than the idle window, and a freshness check answers "not a paste" for every burst regardless of the threshold — the paste submits at its first newline. The hook is never told when the Enter itself arrived, so timing cannot decide that question; the latch can, since anything arriving after the idle window lands in the next batch, by which point settle has released it.

Checked locally: cargo fmt --check, cargo clippy --all-targets --all -- -D warnings across the CI feature matrix plus system_clipboard, cargo test and cargo test --all-features all clean, and both examples exercised by hand in a terminal without bracketed paste. The CI matrix does not build system_clipboard, so paste_interceptor is not compiled there; say the word if you want a matrix entry for it.

image image image

@easyinplay

Copy link
Copy Markdown
Contributor Author

Pushed one more commit tightening the enter_is_newline contract. Writing the example made it clear the doc was pointing implementers the wrong way: it said the hook decides whether an Enter "arriving now" is paste-embedded, which reads as if the arrival timing were usable there, but the hook is never given the Enter's arrival time and for a detected burst the engine only stops draining after a poll_timeout idle poll — so any freshness test answers false for every burst and the paste submits at its first newline. That belongs in the trait rather than only in the example, since it is a property of the call site that every implementation will hit; the doc now spells out when the method is called, requires a detected burst to be answered from the same latch is_burst_active reports, explains why a real submit is still not swallowed, and notes that timing does remain correct on the short-paste path that never gets drained. The trait-level "read the clock itself" line is now scoped to on_char.

@fdncred

fdncred commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Very cool. I have another question. I'm wondering if the traits should have something like an on_cancel() function that cleans things up in the event of a Ctrl-C. I'm not even quite sure how ctrl-c is being caught after glancing through the diff.

@easyinplay

Copy link
Copy Markdown
Contributor Author

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 ReedlineEvent::CtrlC arm (engine.rs, in both the emacs and vi handlers), which sets the input mode back to Regular and returns Signal::CtrlC. The paste hooks don't intercept it or sit anywhere near it.

Whether a cleanup hook is needed: for correctness, no, and the reason is already in the read loop. After each batch is processed, hook.settle() is called unconditionally, before the loop acts on the batch's result:

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 Break(Signal::CtrlC) batch result, so settle() has already run by the time the loop returns — the burst latch and detector state are cleared, and nothing leaks into the next line. settle() is effectively the "reset your transient state" callback, so a host's cleanup already has a home.

The only thing an on_cancel() would add over settle() is letting a host tell cancelled from settled normally — e.g. discarding a stashed partial paste on Ctrl-C but keeping it on a normal settle. Neither hook here needs that distinction, so I'd lean toward leaving it out for now and revisiting it as a follow-up if a concrete use case turns up, rather than widening the trait for a hypothetical. But I'm happy either way if you feel it belongs in.

@fdncred

fdncred commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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?

@kronberger-droid

Copy link
Copy Markdown
Collaborator

Hey @easyinplay, @fdncred
looks mostly clean to me, to quote Claude "unusually well-documented", which is not a bad thing.

One thing that came up during the review with Claude is in the burst contract:

enter_is_newline's doc says detected burst must answer true from the same latch as is_burst_active, but the coalescing loop's submit_after_burst branch only fires when enter_is_newline() is false while is_burst_active() is true, which the doc calls invalid.
So with a hook that follows the contract, is an in-batch real-submit Enter reachable, or does it always get swallowed as \n until the next batch?
paste_burst_real_submit_enter_ends_burst leans on StubBurst { active: true, enter_newline: false }, which is that forbidden state, so i'm not sure the test reflects a real hook.
Am i missing something?

Two smaller things:

  • A resolve_burst test where it returns Some(..) and asserts the buffer picks up the reference, since that path is only exercised with None.
  • A system_clipboard entry in the CI matrix, since the two #[cfg(feature = "system_clipboard")] interceptor tests don't run otherwise.

Fine to land from my side once the burst question settles and those are in.

@easyinplay

Copy link
Copy Markdown
Contributor Author

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 submit_after_burst branch is unreachable, so it was dead code guarding a state the contract forbids — and paste_burst_real_submit_enter_ends_burst was exercising exactly that forbidden state through the stub.

Looking at why the branch felt necessary in the first place made the real answer obvious: it never was. Any Enter that gets drained into a burst batch arrived before an idle poll of poll_timeout found the queue empty — machine timing by construction, so it is a paste-embedded newline by definition, and asking the hook was answering a question the drain loop had already settled. The new commit makes that structural: the engine coalesces every drained Enter as \n unconditionally, and the branch, the per-Enter oracle query, and the ReedlineEvent::Enter push are gone. enter_is_newline is now only consulted on the short-paste path (the aa\nbb case that never reaches the burst threshold), where the chars have just arrived and a freshness test is genuinely the right signal — so the whole "must answer from the latch" paragraph disappears from the trait doc rather than getting more caveats. is_burst_active's latch requirement is unchanged; that one is real. A nice side effect: a paste with a trailing newline can no longer submit the line, which matches how bracketed paste behaves, and a real submit Enter still lands in the batch after settle exactly as before.

The old test is replaced by paste_burst_enter_coalesces_as_newline_without_submit (contract-abiding stub, asserts the Enter joins the coalesced text and the batch does not submit). Your two smaller items are in as well: paste_burst_resolve_burst_inserts_placeholder covers the Some(..) path and asserts the buffer picks up the placeholder instead of the raw text, and the CI matrix now has a system_clipboard entry so the two gated interceptor tests actually run.

cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test, and cargo test --all-features are all clean locally.

@eitsupi

eitsupi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

@easyinplay Here is a fix for the bug of crossterm not supporting bracketed paste mode on Windows.
crossterm-rs/crossterm#1030

However, there is a lack of reviewers and it is unlikely to be merged.
If you are knowledgeable about this, reviewing this PR could be a step towards merging it.

Thank you.

@easyinplay

Copy link
Copy Markdown
Contributor Author

@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 Event::Paste, and where crossterm can deliver one on Windows, that is what reedline should prefer.

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:

  1. Legacy consoles. Your scope table says bracketed paste on the non-VT path is "Not delivered (unchanged)". That's the right call for the PR, but it means a user on a console where ENABLE_VIRTUAL_TERMINAL_INPUT can't be set still gets no paste event.
  2. Terminals that handle Ctrl+V themselves. On Warp the paste never reaches us as a paste at all — the terminal consumes the key and replays the content as a stream of individual key events, with no bracketed-paste markers anywhere in the stream. crossterm can only parse what the terminal actually sends, so correctness in the parser can't recover this one.
  3. Version floor. Even once Prepare Release 0.46.0 #1030 merges and ships, consumers pinned to an older crossterm keep the old behavior for as long as that pin lasts.

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 submit_after_burst branch is gone, enter_is_newline is now only consulted on the short-paste path, the old test is replaced by a contract-abiding one, resolve_burst's Some(..) path is covered, and CI picked up the system_clipboard entry so the two gated interceptor tests actually run. Anything further you'd like changed, or is this good to land?

@eitsupi

eitsupi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@easyinplay Thank you. I've updated crossterm-rs/crossterm#1030

I hope someone reviews this and it gets merged into crossterm.

@easyinplay

Copy link
Copy Markdown
Contributor Author

Rebased onto main and force-pushed — mergeable again.

The only conflict was in Reedline::create, where #1144's deferred_menu_completion landed in the same field initializer as paste_interceptor / paste_burst; both sides kept. The other four commits replayed clean, and the diff against main is still 1054 insertions / 24 deletions, unchanged from before the rebase.

Since #1144 touches the read loop as well, I went through the merged loop by hand rather than trusting a clean textual merge. completer_pending only decides whether the first read blocks, and it runs before the burst block; the burst drain doesn't touch it, and an empty batch just means the detector sees no chars. settle_completions is outside the loop entirely, so the hook.settle() after process_input_batch never meets it. No interaction either way.

Locally: cargo fmt --all --check, cargo clippy --locked --all-targets --all-features -- -D warnings, and cargo test --all --all-features (1353 passed, 1 pre-existing ignore) are all clean. CI here is green too.

@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?

@kronberger-droid

kronberger-droid commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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.
Thanks for the great work!

@easyinplay

Copy link
Copy Markdown
Contributor Author

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 ENABLE_VIRTUAL_TERMINAL_INPUT, so nothing decodes them and the paste arrives as a stream of individual key events. That is a decoding gap, not a terminal that refuses to send. Measured both ways with the same probe on the same machine: on origin/master, Warp gives 657 key events and zero Event::Paste; on #1030's branch the same paste in the same terminal gives one Event::Paste. Windows Terminal and conhost behave identically (659 and 337 key events, zero pastes, versus a clean Paste). Full numbers are in crossterm-rs/crossterm#1030.

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 Paste in another run, most likely a different paste gesture — I didn't record which, so I'm not claiming more than "both behaviors occur there.")

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 Event::Paste, the line separators arrived as lone \r, not \n. The clipboard held LF; Windows Terminal, conhost and OxideTerm all delivered CR. crossterm passes the bytes through verbatim, so that is the terminals' own translation. Content was otherwise byte-exact — normalized SHA-256 of the delivered text matched the source on all three.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: host hooks for paste interception + timing-based paste-burst detection (Warp/ConPTY)

5 participants