diff --git a/Examples/UICatalog/Scenarios/BracketedPasteDemo.cs b/Examples/UICatalog/Scenarios/BracketedPasteDemo.cs
new file mode 100644
index 0000000000..36f0274247
--- /dev/null
+++ b/Examples/UICatalog/Scenarios/BracketedPasteDemo.cs
@@ -0,0 +1,88 @@
+#nullable enable
+
+namespace UICatalog.Scenarios;
+
+[ScenarioMetadata ("Bracketed Paste", "Logs bracketed-paste payloads delivered by the terminal")]
+[ScenarioCategory ("Text and Formatting")]
+public sealed class BracketedPasteDemo : Scenario
+{
+ public override void Main ()
+ {
+ ConfigurationManager.Enable (ConfigLocations.All);
+ using IApplication app = Application.Create ();
+ app.Init ();
+
+ using Window appWindow = new ()
+ {
+ Title = GetQuitKeyAndName ()
+ };
+ appWindow.AssignHotKeys = true;
+
+ Label hint = CreateHintLabel ();
+
+ TextField field = new ()
+ {
+ X = 0,
+ Y = Pos.Bottom (hint) + 1,
+ Width = Dim.Fill (),
+ Title = "Paste into the focused TextField — the default Command.Paste handler inserts the text."
+ };
+
+ Label log = new ()
+ {
+ X = 0,
+ Y = Pos.Bottom (field) + 1,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ Text = "Application.Paste log (only bracketed paste events appear here):\n"
+ };
+
+ int counter = 0;
+
+ app.Paste += (_, args) =>
+ {
+ counter++;
+ log.Text += $"{FormatPasteLogEntry (counter, args.Text)}\n";
+ };
+
+ appWindow.Add (hint, field, log);
+
+ app.Run (appWindow);
+ }
+
+ public static Label CreateHintLabel ()
+ {
+ Label hint = new ()
+ {
+ X = 0,
+ Y = 0,
+ Width = Dim.Fill (),
+ Height = Dim.Auto (DimAutoStyle.Text),
+ Text = "Paste into the TextField below.\n"
+ + "If Terminal.Gui detects bracketed paste, the paste is logged below as a single bracketed-paste event.\n"
+ + "If text appears in the field but no new log entry is added, your terminal delivered it as normal input instead."
+ };
+
+ hint.TextFormatter.WordWrap = true;
+
+ return hint;
+ }
+
+ public static string FormatPasteLogEntry (int counter, string text)
+ {
+ return $"[{counter}] Bracketed paste event: {text.Length} chars: {Truncate (text)}";
+ }
+
+ private static string Truncate (string text)
+ {
+ // Replace control chars so the display stays one line per paste.
+ string flattened = text.Replace ("\r", "\\r").Replace ("\n", "\\n").Replace ("\t", "\\t");
+
+ if (flattened.Length <= 60)
+ {
+ return flattened;
+ }
+
+ return flattened [..60] + "…";
+ }
+}
diff --git a/Terminal.Gui/App/ApplicationImpl.Driver.cs b/Terminal.Gui/App/ApplicationImpl.Driver.cs
index e906a164b0..1e543ead28 100644
--- a/Terminal.Gui/App/ApplicationImpl.Driver.cs
+++ b/Terminal.Gui/App/ApplicationImpl.Driver.cs
@@ -197,6 +197,7 @@ internal void SubscribeDriverEvents ()
Driver.KeyDown += Driver_KeyDown;
Driver.KeyUp += Driver_KeyUp;
Driver.MouseEvent += Driver_MouseEvent;
+ Driver.Paste += Driver_Paste;
}
private void Driver_KeyDown (object? sender, Key e) => Keyboard.RaiseKeyDownEvent (e);
@@ -204,4 +205,6 @@ internal void SubscribeDriverEvents ()
private void Driver_KeyUp (object? sender, Key e) => Keyboard.RaiseKeyUpEvent (e);
private void Driver_MouseEvent (object? sender, Mouse e) => Mouse.RaiseMouseEvent (e);
+
+ private void Driver_Paste (object? sender, string e) => RaisePasteEvent (e);
}
diff --git a/Terminal.Gui/App/ApplicationImpl.cs b/Terminal.Gui/App/ApplicationImpl.cs
index 169c586ba8..786be4095f 100644
--- a/Terminal.Gui/App/ApplicationImpl.cs
+++ b/Terminal.Gui/App/ApplicationImpl.cs
@@ -230,6 +230,41 @@ public IMouse Mouse
set => _mouse = value ?? throw new ArgumentNullException (nameof (value));
}
+ ///
+ public event EventHandler? Paste;
+
+ ///
+ public bool RaisePasteEvent (string text)
+ {
+ PasteEventArgs args = new (text);
+
+ Paste?.Invoke (this, args);
+
+ if (args.Handled)
+ {
+ return true;
+ }
+
+ // Route only to the focused view — paste data is text destined for a text-input control,
+ // and silently dispatching to a non-text container would either drop the paste or surprise
+ // the user. Apps that want to handle pastes without a focused view should subscribe to
+ // Application.Paste and set Handled.
+ View? focused = Navigation?.GetFocused ();
+
+ if (focused is null)
+ {
+ return false;
+ }
+
+ // Dispatch through Command.Paste so bracketed paste shares the same paste handler
+ // (sanitization, Pasting/Pasted events, insertion) as keyboard-driven paste. Use a
+ // dedicated payload object so the handler does not mistake unrelated string-valued command
+ // context entries for pasted text.
+ CommandContext ctx = new (Command.Paste, new WeakReference (focused), binding: null);
+
+ return focused.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload (args.Text))) is true;
+ }
+
#endregion Input (Mouse/Keyboard)
#region Navigation and Popover
diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs
index 64b4d44a0e..3a9da5a3b4 100644
--- a/Terminal.Gui/App/IApplication.cs
+++ b/Terminal.Gui/App/IApplication.cs
@@ -671,6 +671,34 @@ public interface IApplication : IDisposable
///
IMouse Mouse { get; set; }
+ ///
+ /// Raised when the terminal delivers a bracketed paste. Fires before the paste is dispatched
+ /// to the focused view; set on
+ /// the event arguments to to prevent the focused view from receiving
+ /// the paste.
+ ///
+ ///
+ ///
+ /// Bracketed paste mode is enabled automatically by the driver. On terminals that do not
+ /// support bracketed paste (or have it disabled by user configuration) the pasted text is
+ /// delivered as ordinary key events and this event does not fire.
+ ///
+ ///
+ event EventHandler? Paste;
+
+ ///
+ /// Raises the event with , then dispatches the
+ /// paste to the focused view by invoking with a dedicated
+ /// command-context paste payload if not already handled. The default
+ /// handler on sanitizes the payload, raises
+ /// , and delegates insertion to the focused view's
+ /// override. If the focused view consumes the paste and reports
+ /// a final-text segment for the pasted range, the handler raises .
+ ///
+ /// The pasted content with bracketing markers stripped.
+ /// if the paste was handled.
+ bool RaisePasteEvent (string text);
+
#endregion Input (Mouse/Keyboard)
#region Layout and Drawing
diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
index cf8cee1270..0f0b0253fb 100644
--- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
+++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
@@ -145,6 +145,7 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen)
// TODO: Move Input related CSI sequences to AnsiInput
Write (EscSeqUtils.CSI_EnableMouseEvents);
+ Write (EscSeqUtils.CSI_EnableBracketedPaste);
}
else
{
@@ -156,6 +157,7 @@ public AnsiOutput (AppModel appModel = AppModel.FullScreen)
// TODO: Move Input related CSI sequences to AnsiInput
Write (EscSeqUtils.CSI_EnableMouseEvents);
+ Write (EscSeqUtils.CSI_EnableBracketedPaste);
}
// Flush to ensure all sequences are sent
@@ -382,6 +384,7 @@ public void Dispose ()
}
// Restore terminal state: disable mouse, reset attributes, show cursor
+ Write (EscSeqUtils.CSI_DisableBracketedPaste);
Write (EscSeqUtils.CSI_DisableMouseEvents);
Write (EscSeqUtils.CSI_ResetAttributes);
diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs
index ddf658a7f9..b562629e04 100644
--- a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs
+++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserBase.cs
@@ -19,6 +19,27 @@ internal abstract class AnsiResponseParserBase (IHeld heldContent, ITimeProvider
///
internal const int MaxHeldLength = 8 * 1024;
+ ///
+ /// Maximum number of characters allowed in a single bracketed-paste payload. Pastes larger
+ /// than this are truncated and the truncated content is delivered immediately. Any remaining
+ /// bytes from the same paste are discarded until the matching ESC[201~ end marker
+ /// arrives so tail bytes cannot leak into normal input processing. Guards against unbounded
+ /// memory growth from a missing or stripped end marker.
+ ///
+ internal const int MaxBracketedPasteLength = 1 * 1024 * 1024;
+
+ ///
+ /// Buffer accumulating pasted text while the parser is in
+ /// .
+ ///
+ private readonly StringBuilder _pasteBuffer = new ();
+
+ ///
+ /// Trailing characters of that may form the start of the
+ /// ESC[201~ end marker. Tracked so the marker bytes are not delivered as paste content.
+ ///
+ private int _pasteEndMatchLength;
+
///
/// Tracks whether the parser is currently inside an OSC (Operating System Command) sequence.
/// OSC responses can be terminated by ST (ESC \) which requires special handling because
@@ -73,18 +94,25 @@ protected set
///
public DateTime StateChangedAt { get; private set; }
+ ///
+ /// Timestamp when the parser last received a byte belonging to the current bracketed paste.
+ /// Used to detect idle paste sessions without treating an active slow paste as stale.
+ ///
+ internal DateTime LastBracketedPasteInputAt { get; private set; }
+
#endregion
#region Constructor and State Management
protected void ResetState ()
{
- State = AnsiResponseParserState.Normal;
- _inOscSequence = false;
- _oscExpectingBackslash = false;
-
lock (_lockState)
{
+ State = AnsiResponseParserState.Normal;
+ _inOscSequence = false;
+ _oscExpectingBackslash = false;
+ _pasteBuffer.Clear ();
+ _pasteEndMatchLength = 0;
_heldContent.ClearHeld ();
}
}
@@ -106,13 +134,26 @@ protected void ResetState ()
/// The total number of elements in the input collection.
protected void ProcessInputBase (Func getCharAtIndex, Func getObjectAtIndex, Action appendOutput, int inputLength)
{
+ List pendingPastes = [];
+
lock (_lockState)
{
- ProcessInputBaseImpl (getCharAtIndex, getObjectAtIndex, appendOutput, inputLength);
+ ProcessInputBaseImpl (getCharAtIndex, getObjectAtIndex, appendOutput, inputLength, pendingPastes);
+ }
+
+ foreach (string text in pendingPastes)
+ {
+ Paste?.Invoke (this, text);
}
}
- private void ProcessInputBaseImpl (Func getCharAtIndex, Func getObjectAtIndex, Action appendOutput, int inputLength)
+ private void ProcessInputBaseImpl (
+ Func getCharAtIndex,
+ Func getObjectAtIndex,
+ Action appendOutput,
+ int inputLength,
+ List pendingPastes
+ )
{
var index = 0; // Tracks position in the input string
@@ -169,6 +210,23 @@ private void ProcessInputBaseImpl (Func getCharAtIndex, Func= MaxBracketedPasteLength)
+ {
+ FlushPaste (AnsiResponseParserState.DiscardingBracketedPasteRemainder, pendingPastes);
+ }
+
+ break;
+
+ case AnsiResponseParserState.DiscardingBracketedPasteRemainder:
+ NoteBracketedPasteActivity ();
+ DiscardBracketedPasteRemainder (currentChar);
+
+ break;
+
case AnsiResponseParserState.InResponse:
// Guard against unbounded memory growth from malformed/unterminated sequences
@@ -336,6 +394,20 @@ private bool HandleHeldContent ()
{
string cur = _heldContent.HeldToString ();
+ // Bracketed paste start (ESC[200~). Switch into paste-collecting mode and discard
+ // the marker. Subsequent input is accumulated as paste content until the matching
+ // end marker (ESC[201~) is seen — see ProcessInputBaseImpl InBracketedPaste case.
+ if (cur == EscSeqUtils.CSI_BracketedPasteStart)
+ {
+ _heldContent.ClearHeld ();
+ _pasteBuffer.Clear ();
+ _pasteEndMatchLength = 0;
+ State = AnsiResponseParserState.InBracketedPaste;
+ NoteBracketedPasteActivity ();
+
+ return false;
+ }
+
if (HandleMouse && IsMouse (cur))
{
// See https://github.com/gui-cs/Terminal.Gui/issues/4587#issuecomment-3770132337 for why
@@ -635,4 +707,129 @@ protected void RaiseKeyboardEvent (AnsiKeyboardParserPattern pattern, string? cu
}
#endregion
+
+ #region Bracketed Paste Handling
+
+ ///
+ /// Event raised when a complete bracketed paste sequence is detected. The string carries the
+ /// pasted content with the bracketing markers (ESC[200~ / ESC[201~ ) stripped.
+ ///
+ ///
+ /// Bracketed paste mode must be enabled by writing
+ /// to the terminal. Without it, terminals deliver pasted text as raw input characters which are
+ /// indistinguishable from typing.
+ ///
+ public event EventHandler? Paste;
+
+ private void AppendToPaste (char c, List pendingPastes)
+ {
+ _pasteBuffer.Append (c);
+
+ if (!AdvanceBracketedPasteEndMatch (c))
+ {
+ return;
+ }
+
+ // Full end marker matched — strip its characters from the buffer and dispatch.
+ _pasteBuffer.Length -= EscSeqUtils.CSI_BracketedPasteEnd.Length;
+ FlushPaste (AnsiResponseParserState.Normal, pendingPastes);
+ }
+
+ private void DiscardBracketedPasteRemainder (char c)
+ {
+ if (!AdvanceBracketedPasteEndMatch (c))
+ {
+ return;
+ }
+
+ _pasteEndMatchLength = 0;
+ State = AnsiResponseParserState.Normal;
+ }
+
+ private bool AdvanceBracketedPasteEndMatch (char c)
+ {
+ // Track an in-flight suffix match against ESC[201~ so we can strip or discard the marker
+ // without scanning the whole buffer on every character.
+ string endMarker = EscSeqUtils.CSI_BracketedPasteEnd;
+
+ if (_pasteEndMatchLength < endMarker.Length && c == endMarker [_pasteEndMatchLength])
+ {
+ _pasteEndMatchLength++;
+ }
+ else
+ {
+ // Mismatch: ESC[201~ has no repeated prefix, so the only restart is at a fresh ESC.
+ _pasteEndMatchLength = c == ESCAPE ? 1 : 0;
+ }
+
+ return _pasteEndMatchLength == endMarker.Length;
+ }
+
+ private string DrainPasteBuffer (AnsiResponseParserState nextState)
+ {
+ if (nextState == AnsiResponseParserState.DiscardingBracketedPasteRemainder && _pasteEndMatchLength > 0)
+ {
+ _pasteBuffer.Length -= _pasteEndMatchLength;
+ }
+
+ string text = _pasteBuffer.ToString ();
+ _pasteBuffer.Clear ();
+
+ if (nextState != AnsiResponseParserState.DiscardingBracketedPasteRemainder)
+ {
+ _pasteEndMatchLength = 0;
+ }
+
+ State = nextState;
+
+ return text;
+ }
+
+ private void FlushPaste (AnsiResponseParserState nextState, List pendingPastes)
+ {
+ string text = DrainPasteBuffer (nextState);
+ pendingPastes.Add (text);
+ }
+
+ private void NoteBracketedPasteActivity () => LastBracketedPasteInputAt = _timeProvider.Now;
+
+ ///
+ /// Flushes any in-flight bracketed-paste buffer as a event and returns the
+ /// parser to . Called by the input processor when
+ /// the paste has been idle too long, so a terminal that drops the ESC[201~ end marker
+ /// does not strand pasted content forever.
+ ///
+ ///
+ /// if the parser was reset from a bracketed-paste state;
+ /// if the parser was not in a bracketed-paste state.
+ ///
+ internal bool FlushStaleBracketedPaste ()
+ {
+ string? text = null;
+
+ lock (_lockState)
+ {
+ if (State == AnsiResponseParserState.InBracketedPaste)
+ {
+ text = DrainPasteBuffer (AnsiResponseParserState.Normal);
+ }
+ else if (State == AnsiResponseParserState.DiscardingBracketedPasteRemainder)
+ {
+ ResetState ();
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ if (text is { })
+ {
+ Paste?.Invoke (this, text);
+ }
+
+ return true;
+ }
+
+ #endregion
}
diff --git a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserState.cs b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserState.cs
index 41f0912c1d..07535d7221 100644
--- a/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserState.cs
+++ b/Terminal.Gui/Drivers/AnsiHandling/AnsiResponseParserState.cs
@@ -21,5 +21,17 @@ public enum AnsiResponseParserState
/// Parser has encountered Esc[ and considers that it is in the process
/// of reading an ANSI sequence.
///
- InResponse
+ InResponse,
+
+ ///
+ /// Parser has encountered the bracketed-paste start sequence (ESC[200~ ) and is
+ /// accumulating pasted content until the matching end sequence (ESC[201~ ).
+ ///
+ InBracketedPaste,
+
+ ///
+ /// Parser has already delivered the truncated prefix of an oversized bracketed paste and is
+ /// discarding the remaining bytes until the matching end sequence (ESC[201~ ) arrives.
+ ///
+ DiscardingBracketedPasteRemainder
}
diff --git a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
index e967608bad..ca46525599 100644
--- a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
+++ b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
@@ -279,6 +279,43 @@ public enum ClearScreenOptions
#endregion Mouse
+ #region Bracketed Paste
+
+ ///
+ /// ESC [ ? 2004 h - Enable bracketed paste mode.
+ ///
+ ///
+ ///
+ /// When bracketed paste mode is enabled, the terminal wraps pasted content with the start
+ /// marker (ESC[200~ ) and the end marker
+ /// (ESC[201~ ). This lets applications distinguish
+ /// pasted text from typed input and handle large pastes efficiently.
+ ///
+ ///
+ /// Supported by xterm, Windows Terminal, iTerm2, and most modern terminal emulators.
+ ///
+ ///
+ public static readonly string CSI_EnableBracketedPaste = CSI + "?2004h";
+
+ ///
+ /// ESC [ ? 2004 l - Disable bracketed paste mode.
+ ///
+ public static readonly string CSI_DisableBracketedPaste = CSI + "?2004l";
+
+ ///
+ /// ESC [ 200 ~ - Sequence emitted by the terminal at the start of pasted content when
+ /// bracketed paste mode is enabled via .
+ ///
+ public static readonly string CSI_BracketedPasteStart = CSI + "200~";
+
+ ///
+ /// ESC [ 201 ~ - Sequence emitted by the terminal at the end of pasted content when
+ /// bracketed paste mode is enabled via .
+ ///
+ public static readonly string CSI_BracketedPasteEnd = CSI + "201~";
+
+ #endregion Bracketed Paste
+
#region Keyboard
///
diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs
index 12b7a95830..e847099fa3 100644
--- a/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs
+++ b/Terminal.Gui/Drivers/DotNetDriver/NetInput.cs
@@ -56,6 +56,9 @@ public NetInput ()
// Mode 1015 (URXVT) - UTF-8 coordinate encoding (fallback for older terminals)
// Mode 1006 (SGR) - Modern decimal format with unlimited coordinates (preferred)
Console.Out.Write (EscSeqUtils.CSI_EnableMouseEvents);
+
+ // Mode 2004 - Bracketed paste mode. Pastes are wrapped in ESC[200~ ... ESC[201~.
+ Console.Out.Write (EscSeqUtils.CSI_EnableBracketedPaste);
Console.TreatControlCAsInput = true;
}
catch
@@ -73,7 +76,8 @@ public override void Dispose ()
try
{
- // Disable mouse events first
+ // Disable bracketed paste and mouse events first
+ Console.Out.Write (EscSeqUtils.CSI_DisableBracketedPaste);
Console.Out.Write (EscSeqUtils.CSI_DisableMouseEvents);
//Disable alternative screen buffer.
diff --git a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
index 0474809d34..df888831f2 100644
--- a/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
+++ b/Terminal.Gui/Drivers/DotNetDriver/NetOutput.cs
@@ -189,7 +189,8 @@ public void Suspend ()
// Best-effort: mirror behavior of ANSI/Unix outputs for consoles that accept CSI sequences.
try
{
- // Disable mouse events to prevent mouse events from being sent to the application while it is suspended.
+ // Disable bracketed paste and mouse events while suspended.
+ Write (EscSeqUtils.CSI_DisableBracketedPaste);
Write (EscSeqUtils.CSI_DisableMouseEvents);
// Check if we have a real console first
@@ -227,8 +228,9 @@ public void Suspend ()
}
finally
{
- // Enable mouse events to allow mouse events to be sent to the application when it is resumed.
+ // Re-enable mouse events and bracketed paste after resume.
Write (EscSeqUtils.CSI_EnableMouseEvents);
+ Write (EscSeqUtils.CSI_EnableBracketedPaste);
}
}
}
diff --git a/Terminal.Gui/Drivers/DriverImpl.cs b/Terminal.Gui/Drivers/DriverImpl.cs
index 997052cdb0..f1efe43e46 100644
--- a/Terminal.Gui/Drivers/DriverImpl.cs
+++ b/Terminal.Gui/Drivers/DriverImpl.cs
@@ -70,6 +70,7 @@ public DriverImpl (IComponentFactory componentFactory,
_inputProcessor.KeyDown += (s, e) => KeyDown?.Invoke (s, e);
_inputProcessor.KeyUp += (s, e) => KeyUp?.Invoke (s, e);
_inputProcessor.SyntheticMouseEvent += (s, e) => MouseEvent?.Invoke (s, e);
+ _inputProcessor.Paste += (s, e) => Paste?.Invoke (s, e);
_outputBuffer = outputBuffer;
_output = output;
_ansiRequestScheduler = ansiRequestScheduler;
@@ -485,6 +486,9 @@ public void SetTerminalTitle (string title, int mode = 0)
/// Event fired when a mouse event occurs.
public event EventHandler? MouseEvent;
+ /// Event fired when a bracketed paste is received from the terminal.
+ public event EventHandler? Paste;
+
#endregion Input Events
#region ANSI Escape Sequences
diff --git a/Terminal.Gui/Drivers/IDriver.cs b/Terminal.Gui/Drivers/IDriver.cs
index efc5e23b75..4558cb4405 100644
--- a/Terminal.Gui/Drivers/IDriver.cs
+++ b/Terminal.Gui/Drivers/IDriver.cs
@@ -424,6 +424,13 @@ public interface IDriver : IDisposable
/// Event fired when a mouse event occurs.
event EventHandler? MouseEvent;
+ ///
+ /// Event fired when a bracketed paste is received from the terminal. The string contains the
+ /// raw pasted text with the ANSI bracketing markers stripped. Only fires on terminals that
+ /// support bracketed paste mode (most modern terminals).
+ ///
+ event EventHandler? Paste;
+
#endregion Input Events
#region ANSI Escape Sequences
diff --git a/Terminal.Gui/Drivers/Input/IInputProcessor.cs b/Terminal.Gui/Drivers/Input/IInputProcessor.cs
index bbef633e5c..339f013a1a 100644
--- a/Terminal.Gui/Drivers/Input/IInputProcessor.cs
+++ b/Terminal.Gui/Drivers/Input/IInputProcessor.cs
@@ -103,4 +103,25 @@ public interface IInputProcessor
event EventHandler? AnsiSequenceSwallowed;
#endregion
+
+ #region Paste Events
+
+ ///
+ /// Event raised when bracketed paste content is delivered. The string contains the raw pasted
+ /// text with the ANSI bracketing markers stripped.
+ ///
+ ///
+ /// Bracketed paste mode must be enabled by the driver (typically by writing
+ /// at startup). On terminals that do not
+ /// support bracketed paste, pasted text is delivered as ordinary key events instead.
+ ///
+ event EventHandler? Paste;
+
+ ///
+ /// Raises the event. For unit tests and driver implementations.
+ ///
+ /// The pasted content with bracketing markers stripped.
+ void RaisePasteEvent (string text);
+
+ #endregion
}
diff --git a/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs b/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs
index d72831ee74..678f20e27f 100644
--- a/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs
+++ b/Terminal.Gui/Drivers/Input/InputProcessorImpl.cs
@@ -16,6 +16,15 @@ public abstract class InputProcessorImpl : IInputProcessor, IDispo
///
private readonly TimeSpan _escTimeout = TimeSpan.FromMilliseconds (50);
+ ///
+ /// Timeout for detecting a stranded bracketed-paste session. If the terminal opens a paste with
+ /// ESC[200~ but never delivers the matching ESC[201~ end marker (dropped
+ /// connection, broken terminal), the buffered content is flushed after this duration so input
+ /// resumes flowing. Pastes legitimately span seconds for large clipboards, so this is much
+ /// longer than .
+ ///
+ private readonly TimeSpan _bracketedPasteTimeout = TimeSpan.FromSeconds (5);
+
///
/// ANSI response parser for handling escape sequences from the input stream.
///
@@ -67,6 +76,8 @@ protected InputProcessorImpl (ConcurrentQueue inputBuffer, IKeyCon
// Enable keyboard handling
Parser.HandleKeyboard = true;
+ Parser.Paste += (_, text) => RaisePasteEvent (text);
+
Parser.Keyboard += (_, keyEvent) =>
{
Key normalizedKeyEvent = OnKeyboardEventParsed (keyEvent);
@@ -120,10 +131,35 @@ public void ProcessQueue ()
ProcessAfterParsing (input);
}
+ FlushStaleBracketedPasteIfNeeded ();
+
// Check for expired deferred clicks
CheckForExpiredMouseClicks ();
}
+ private void FlushStaleBracketedPasteIfNeeded ()
+ {
+ if (Parser.State is not AnsiResponseParserState.InBracketedPaste
+ and not AnsiResponseParserState.DiscardingBracketedPasteRemainder)
+ {
+ return;
+ }
+
+ if (_timeProvider.Now - Parser.LastBracketedPasteInputAt < _bracketedPasteTimeout)
+ {
+ return;
+ }
+
+ Logging.Warning (
+ $"{
+ nameof (InputProcessorImpl)
+ } abandoning stale bracketed-paste state after {
+ _bracketedPasteTimeout.TotalSeconds
+ }s without activity");
+
+ Parser.FlushStaleBracketedPaste ();
+ }
+
///
/// Checks for and emits any deferred single-click events that have exceeded the double-click threshold.
///
@@ -340,6 +376,16 @@ public void RaiseSyntheticMouseEvent (Mouse mouse)
#endregion
+ #region Paste Events
+
+ ///
+ public event EventHandler? Paste;
+
+ ///
+ public void RaisePasteEvent (string text) => Paste?.Invoke (this, text);
+
+ #endregion
+
#region Disposal
///
diff --git a/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs b/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs
index fcaa83e7d0..b45aecee19 100644
--- a/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs
+++ b/Terminal.Gui/Drivers/UnixHelpers/UnixTerminalHelper.cs
@@ -107,7 +107,8 @@ public static void Suspend (IOutput output)
try
{
- // Disable mouse events to prevent mouse events from being sent to the application while it is suspended.
+ // Disable bracketed paste and mouse events while suspended.
+ output.Write (EscSeqUtils.CSI_DisableBracketedPaste);
output.Write (EscSeqUtils.CSI_DisableMouseEvents);
// Check if we have a real console first
@@ -154,8 +155,9 @@ public static void Suspend (IOutput output)
}
finally
{
- // Enable mouse events to allow mouse events to be sent to the application when it is resumed.
+ // Re-enable mouse events and bracketed paste after resume.
output.Write (EscSeqUtils.CSI_EnableMouseEvents);
+ output.Write (EscSeqUtils.CSI_EnableBracketedPaste);
}
}
diff --git a/Terminal.Gui/Input/PasteEventArgs.cs b/Terminal.Gui/Input/PasteEventArgs.cs
new file mode 100644
index 0000000000..851f08fa9b
--- /dev/null
+++ b/Terminal.Gui/Input/PasteEventArgs.cs
@@ -0,0 +1,36 @@
+using System.ComponentModel;
+
+namespace Terminal.Gui.Input;
+
+///
+/// Event arguments for the application-level event. Carries the
+/// raw payload delivered by the terminal's bracketed-paste mode, before any view-level
+/// sanitization. Set to to stop
+/// the paste from being dispatched to the focused view.
+///
+///
+///
+/// Bracketed paste delivers the entire pasted payload as a single string, distinct from
+/// keyboard input. Subscribers at the application boundary observe the unmodified text.
+/// View-level sanitization (line-ending normalization, control-character stripping) is
+/// performed downstream by when the
+/// handler runs.
+///
+///
+public class PasteEventArgs : HandledEventArgs
+{
+ ///
+ /// Initializes a new .
+ ///
+ /// The pasted text with bracketing markers stripped.
+ public PasteEventArgs (string text) { Text = text; }
+
+ ///
+ /// The pasted text. Bracketing markers (ESC[200~ / ESC[201~ ) are stripped by
+ /// the parser and are never present in this string.
+ ///
+ public string Text { get; }
+
+ ///
+ public override string ToString () => $"Paste ({Text.Length} chars)";
+}
diff --git a/Terminal.Gui/Input/PastePayload.cs b/Terminal.Gui/Input/PastePayload.cs
new file mode 100644
index 0000000000..c4e73e3a51
--- /dev/null
+++ b/Terminal.Gui/Input/PastePayload.cs
@@ -0,0 +1,8 @@
+namespace Terminal.Gui.Input;
+
+///
+/// Dedicated command-context payload used to carry bracketed-paste text through
+/// without colliding with unrelated values already
+/// present in the command context.
+///
+internal readonly record struct PastePayload (string Text);
diff --git a/Terminal.Gui/Input/PastedEventArgs.cs b/Terminal.Gui/Input/PastedEventArgs.cs
new file mode 100644
index 0000000000..d7d0083173
--- /dev/null
+++ b/Terminal.Gui/Input/PastedEventArgs.cs
@@ -0,0 +1,22 @@
+namespace Terminal.Gui.Input;
+
+///
+/// Event arguments for the event raised after the default
+/// handler has consumed a paste. Observation only — handlers cannot
+/// cancel or alter what has already been inserted.
+///
+public class PastedEventArgs : EventArgs
+{
+ /// Initializes a new .
+ /// The final-text segment corresponding to the pasted range.
+ public PastedEventArgs (string text) { Text = text; }
+
+ ///
+ /// The final-text segment corresponding to the pasted range (post-sanitization,
+ /// post- ).
+ ///
+ public string Text { get; }
+
+ ///
+ public override string ToString () => $"Pasted ({Text.Length} chars)";
+}
diff --git a/Terminal.Gui/Input/PastingEventArgs.cs b/Terminal.Gui/Input/PastingEventArgs.cs
new file mode 100644
index 0000000000..4139dd736b
--- /dev/null
+++ b/Terminal.Gui/Input/PastingEventArgs.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel;
+
+namespace Terminal.Gui.Input;
+
+///
+/// Event arguments for the cancellable event raised by the default
+/// handler. is mutable so subscribers can
+/// normalize or filter the payload before the view inserts it; set
+/// to to cancel the paste.
+///
+public class PastingEventArgs : HandledEventArgs
+{
+ /// Initializes a new .
+ /// The (already sanitized) pasted text that the view is about to insert.
+ public PastingEventArgs (string text) { Text = text; }
+
+ ///
+ /// The pasted text the view is about to insert. Already sanitized by
+ /// . Subscribers may replace it with a different string
+ /// to alter what gets inserted.
+ ///
+ public string Text { get; set; }
+
+ ///
+ public override string ToString () => $"Pasting ({Text.Length} chars)";
+}
diff --git a/Terminal.Gui/ViewBase/View.Command.cs b/Terminal.Gui/ViewBase/View.Command.cs
index 427620807f..6163ee130f 100644
--- a/Terminal.Gui/ViewBase/View.Command.cs
+++ b/Terminal.Gui/ViewBase/View.Command.cs
@@ -22,6 +22,10 @@ private void SetupCommands ()
// NotBound - Invoked if no handler is bound
AddCommand (Command.NotBound, DefaultCommandNotBoundHandler);
+
+ // Paste - Default handler resolves payload (bracketed paste payload or clipboard),
+ // sanitizes, raises Pasting/Pasted, and delegates insertion to OnPaste.
+ AddCommand (Command.Paste, DefaultPasteHandler);
}
#region Command Management
diff --git a/Terminal.Gui/ViewBase/View.Paste.cs b/Terminal.Gui/ViewBase/View.Paste.cs
new file mode 100644
index 0000000000..2b419d8ce3
--- /dev/null
+++ b/Terminal.Gui/ViewBase/View.Paste.cs
@@ -0,0 +1,176 @@
+using System.Text;
+
+namespace Terminal.Gui.ViewBase;
+
+public partial class View // Paste APIs
+{
+ private protected bool CurrentPasteUsesPayload { get; private set; }
+
+ ///
+ /// Default handler for . Resolves the paste payload (from a
+ /// dedicated when bracketed paste delivered one, otherwise from
+ /// ), sanitizes it via ,
+ /// raises the cancellable event, calls to insert
+ /// the text, then raises if insertion actually occurred.
+ ///
+ ///
+ ///
+ /// Plain instances return from
+ /// by default. Subclasses that accept text (for example
+ /// and ) override
+ /// to perform the insertion.
+ ///
+ ///
+ /// Bracketed-paste payloads carry the raw bytes delivered by the terminal between
+ /// ESC[200~ and ESC[201~ . Keyboard-driven pastes (Ctrl+V ) have no
+ /// payload and fall through to the clipboard. Both paths share this handler, so any
+ /// sanitization or event subscription works for both.
+ ///
+ ///
+ ///
+ /// if the paste was consumed (sanitized text inserted, or cancelled
+ /// by a subscriber); if nothing was pasted.
+ ///
+ private bool? DefaultPasteHandler (ICommandContext? ctx)
+ {
+ if (!Enabled)
+ {
+ return false;
+ }
+
+ PastePayload? pastePayload = ctx?.Value is PastePayload value ? value : null;
+ bool usesPayload = pastePayload is { };
+ string? payload = usesPayload ? pastePayload.Value.Text : App?.Clipboard?.GetClipboardData ();
+
+ if (string.IsNullOrEmpty (payload))
+ {
+ return false;
+ }
+
+ string sanitized = OnSanitizingPaste (payload);
+
+ if (string.IsNullOrEmpty (sanitized))
+ {
+ return false;
+ }
+
+ PastingEventArgs pasting = new (sanitized);
+ Pasting?.Invoke (this, pasting);
+
+ if (pasting.Handled)
+ {
+ return true;
+ }
+
+ if (string.IsNullOrEmpty (pasting.Text))
+ {
+ return false;
+ }
+
+ CurrentPasteUsesPayload = usesPayload;
+
+ try
+ {
+ if (!OnPaste (pasting.Text))
+ {
+ return false;
+ }
+
+ string? pastedText = GetPastedEventText (pasting.Text);
+
+ if (ShouldRaisePastedEvent (pasting.Text) && !string.IsNullOrEmpty (pastedText))
+ {
+ Pasted?.Invoke (this, new (pastedText));
+ }
+
+ return true;
+ }
+ finally
+ {
+ CurrentPasteUsesPayload = false;
+ }
+ }
+
+ ///
+ /// Override to filter or transform raw paste payloads before they are inserted into the
+ /// view. The default implementation strips C0/C1 control characters (including ESC) but
+ /// preserves tab, line feed, and carriage return — matching Windows Terminal's
+ /// FilterStringForPaste baseline.
+ ///
+ ///
+ ///
+ /// overrides this to take only the first line and drop tab/CR/LF.
+ /// overrides this to normalize \r\n / \r to \n .
+ ///
+ ///
+ /// The raw payload, either from the terminal or the clipboard.
+ /// The sanitized text that will be passed to .
+ protected virtual string OnSanitizingPaste (string raw) => StripControlCharsExceptTabAndNewline (raw);
+
+ ///
+ /// Override to insert sanitized paste text into the view. The default returns
+ /// because a plain has no text model. Text-input
+ /// views ( , ) override this to perform the
+ /// insertion.
+ ///
+ /// The sanitized text to insert. Never or empty.
+ /// if the view consumed the paste.
+ protected virtual bool OnPaste (string text) => false;
+
+ ///
+ /// Override to suppress when consumes a paste
+ /// without inserting the text.
+ ///
+ /// The sanitized text passed to .
+ ///
+ /// to raise ; when the
+ /// paste was consumed without insertion.
+ ///
+ protected virtual bool ShouldRaisePastedEvent (string text) => true;
+
+ ///
+ /// Override to provide the text for after has run.
+ ///
+ ///
+ ///
+ /// The default returns the sanitized text passed into .
+ /// Views that rewrite or partially reject the paste during insertion can override this to
+ /// report the segment of final view text that corresponds to the pasted range.
+ ///
+ ///
+ /// The sanitized text passed to .
+ ///
+ /// The text to expose through ; return to suppress
+ /// the event.
+ ///
+ protected virtual string? GetPastedEventText (string text) => text;
+
+ ///
+ /// Raised by the default handler after sanitization but before
+ /// insertion. Subscribers may rewrite or set
+ /// to cancel.
+ ///
+ public event EventHandler? Pasting;
+
+ ///
+ /// Raised by the default handler after
+ /// consumes a paste and the view reports that the text was inserted. Observation only — the
+ /// text has already been inserted.
+ ///
+ public event EventHandler? Pasted;
+
+ private static string StripControlCharsExceptTabAndNewline (string text)
+ {
+ StringBuilder sb = new (text.Length);
+
+ foreach (char c in text)
+ {
+ if (c == '\t' || c == '\n' || c == '\r' || (c >= 0x20 && c < 0x7F) || c >= 0xA0)
+ {
+ sb.Append (c);
+ }
+ }
+
+ return sb.ToString ();
+ }
+}
diff --git a/Terminal.Gui/Views/TextInput/TextField/TextField.Commands.cs b/Terminal.Gui/Views/TextInput/TextField/TextField.Commands.cs
index 734a307deb..ad3fce19ef 100644
--- a/Terminal.Gui/Views/TextInput/TextField/TextField.Commands.cs
+++ b/Terminal.Gui/Views/TextInput/TextField/TextField.Commands.cs
@@ -85,7 +85,9 @@ private void CreateCommandsAndBindings ()
AddCommand (Command.DisableOverwrite, () => SetOverwrite (false));
AddCommand (Command.Copy, () => Copy ());
AddCommand (Command.Cut, () => Cut ());
- AddCommand (Command.Paste, () => Paste ());
+
+ // Command.Paste uses the default View base handler — sanitization via OnSanitizingPaste,
+ // insertion via OnPaste. No explicit AddCommand needed.
AddCommand (Command.SelectAll, () => SelectAll ());
AddCommand (Command.DeleteAll, () => DeleteAll ());
AddCommand (Command.Context, () => ShowContextMenu (true));
@@ -150,35 +152,13 @@ public bool MoveEnd ()
return true;
}
- /// Paste the selected text from the clipboard.
- public bool Paste ()
- {
- if (ReadOnly)
- {
- return true;
- }
-
- string? cbTxt = App?.Clipboard?.GetClipboardData ().Split ("\n") [0];
-
- if (string.IsNullOrEmpty (cbTxt))
- {
- return false;
- }
-
- SetSelectedStartSelectedLength ();
- int selStart = _selectionStart == -1 ? InsertionPoint : _selectionStart;
-
- Text = StringExtensions.ToString (_text.GetRange (0, selStart))
- + cbTxt
- + StringExtensions.ToString (_text.GetRange (selStart + SelectedLength, _text.Count - (selStart + SelectedLength)));
-
- _insertionPoint = Math.Min (selStart + GraphemeHelper.GetGraphemeCount (cbTxt), _text.Count);
- ClearAllSelection ();
- SetNeedsDraw ();
- Adjust ();
-
- return true;
- }
+ /// Paste the clipboard contents into the text field at the cursor / selection.
+ ///
+ /// Routes through with a payload, so the
+ /// default handler reads from ,
+ /// sanitizes via , and inserts via .
+ ///
+ public bool Paste () => InvokeCommand (Command.Paste) is true;
/// Redoes the latest changes.
public bool Redo ()
diff --git a/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs b/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs
index 3b28461c30..b6fce8850d 100644
--- a/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs
+++ b/Terminal.Gui/Views/TextInput/TextField/TextField.Text.cs
@@ -6,6 +6,7 @@ namespace Terminal.Gui.Views;
public partial class TextField
{
private CultureInfo _currentCulture;
+ private string? _lastPastedText;
/// Raised before changes. The change can be canceled the text adjusted.
public event EventHandler>? TextChanging;
@@ -39,6 +40,262 @@ public void InsertText (string toAdd, bool useOldCursorPos = true)
}
}
+ ///
+ ///
+ /// TextField is single-line — keep only the first line of the paste and drop C0/C1 control
+ /// characters, including tab. This matches what can actually accept and
+ /// keeps aligned with the text that will be inserted.
+ ///
+ protected override string OnSanitizingPaste (string raw)
+ {
+ int newline = raw.IndexOfAny (['\r', '\n']);
+ string firstLine = newline >= 0 ? raw [..newline] : raw;
+
+ StringBuilder sb = new (firstLine.Length);
+
+ foreach (char c in firstLine)
+ {
+ if ((c >= 0x20 && c < 0x7F) || c >= 0xA0)
+ {
+ sb.Append (c);
+ }
+ }
+
+ return sb.ToString ();
+ }
+
+ ///
+ ///
+ /// Returns even when so that Ctrl+V
+ /// does not bubble to a parent view that might also bind paste.
+ ///
+ protected override bool OnPaste (string text)
+ {
+ _lastPastedText = null;
+
+ if (ReadOnly)
+ {
+ return true;
+ }
+
+ SetSelectedStartSelectedLength ();
+ int selStart = _selectionStart == -1 ? InsertionPoint : _selectionStart;
+ int selectedLength = SelectedLength;
+ List oldText = [.. _text];
+ string oldTextString = StringExtensions.ToString (oldText);
+ List pastedText = text.ToStringList ();
+ List proposedText = [.. oldText.GetRange (0, selStart),
+ .. pastedText,
+ .. oldText.GetRange (selStart + selectedLength, oldText.Count - (selStart + selectedLength))];
+
+ Text = StringExtensions.ToString (proposedText);
+ bool proposedEqualsFinal = AreEqual (proposedText, _text);
+
+ if (oldTextString == Text && !proposedEqualsFinal)
+ {
+ return true;
+ }
+
+ GetActualPastedText (text, proposedText, selStart, pastedText.Count, _text, out string actualPastedText, out int insertionPoint);
+
+ if (string.IsNullOrEmpty (actualPastedText))
+ {
+ _insertionPoint = insertionPoint;
+ ClearAllSelection ();
+ SetNeedsDraw ();
+ Adjust ();
+
+ return true;
+ }
+
+ _lastPastedText = actualPastedText;
+ _insertionPoint = insertionPoint;
+ ClearAllSelection ();
+ SetNeedsDraw ();
+ Adjust ();
+
+ return true;
+ }
+
+ ///
+ protected override bool ShouldRaisePastedEvent (string text) => !ReadOnly && !string.IsNullOrEmpty (_lastPastedText);
+
+ ///
+ protected override string? GetPastedEventText (string text) => _lastPastedText;
+
+ private static void GetActualPastedText (string text, List proposedText, int pasteStart, int pastedLength, List finalText, out string actualPastedText, out int insertionPoint)
+ {
+ if (AreEqual (proposedText, finalText))
+ {
+ actualPastedText = text;
+ insertionPoint = Math.Min (pasteStart + pastedLength, finalText.Count);
+
+ return;
+ }
+
+ if (proposedText.Count == finalText.Count)
+ {
+ int actualPastedLength = Math.Min (pastedLength, finalText.Count - pasteStart);
+ actualPastedText = actualPastedLength > 0
+ ? StringExtensions.ToString (finalText.GetRange (pasteStart, actualPastedLength))
+ : string.Empty;
+ insertionPoint = Math.Min (pasteStart + actualPastedLength, finalText.Count);
+
+ return;
+ }
+
+ actualPastedText = GetActualPastedTextFromEditOperations (proposedText, pasteStart, pastedLength, finalText, out insertionPoint);
+ }
+
+ private static string GetActualPastedTextFromEditOperations (List proposedText, int pasteStart, int pastedLength, List finalText, out int insertionPoint)
+ {
+ List operations = BuildPasteEditOperations (proposedText, finalText);
+ int pasteEnd = pasteStart + pastedLength;
+ int proposedIndex = 0;
+ int finalIndex = 0;
+ int pastedStart = -1;
+ int pastedEnd = -1;
+ int boundaryAfterPaste = -1;
+
+ foreach (PasteEditOperation operation in operations)
+ {
+ if (ConsumesFinal (operation)
+ && IsWithinPasteRange (operation, proposedIndex, pasteStart, pasteEnd))
+ {
+ if (pastedStart == -1)
+ {
+ pastedStart = finalIndex;
+ }
+
+ pastedEnd = finalIndex + 1;
+ }
+
+ if (ConsumesProposed (operation))
+ {
+ proposedIndex++;
+
+ if (proposedIndex == pasteEnd)
+ {
+ boundaryAfterPaste = finalIndex + (ConsumesFinal (operation) ? 1 : 0);
+ }
+ }
+ if (ConsumesFinal (operation))
+ {
+ finalIndex++;
+ }
+ }
+
+ insertionPoint = boundaryAfterPaste == -1 ? finalIndex : boundaryAfterPaste;
+
+ if (pastedStart == -1 || pastedEnd == -1 || pastedEnd <= pastedStart)
+ {
+ return string.Empty;
+ }
+
+ return StringExtensions.ToString (finalText.GetRange (pastedStart, pastedEnd - pastedStart));
+ }
+
+ private static List BuildPasteEditOperations (List proposedText, List finalText)
+ {
+ int [,] costs = new int [proposedText.Count + 1, finalText.Count + 1];
+
+ for (int i = 0; i <= proposedText.Count; i++)
+ {
+ costs [i, 0] = i;
+ }
+
+ for (int j = 0; j <= finalText.Count; j++)
+ {
+ costs [0, j] = j;
+ }
+
+ for (int i = 1; i <= proposedText.Count; i++)
+ {
+ for (int j = 1; j <= finalText.Count; j++)
+ {
+ int substitutionCost = costs [i - 1, j - 1] + (proposedText [i - 1] == finalText [j - 1] ? 0 : 1);
+ int insertionCost = costs [i, j - 1] + 1;
+ int deletionCost = costs [i - 1, j] + 1;
+
+ costs [i, j] = Math.Min (substitutionCost, Math.Min (insertionCost, deletionCost));
+ }
+ }
+
+ List operations = [];
+ int proposedIndex = proposedText.Count;
+ int finalIndex = finalText.Count;
+
+ while (proposedIndex > 0 || finalIndex > 0)
+ {
+ if (proposedIndex > 0
+ && finalIndex > 0
+ && costs [proposedIndex, finalIndex]
+ == costs [proposedIndex - 1, finalIndex - 1]
+ + (proposedText [proposedIndex - 1] == finalText [finalIndex - 1] ? 0 : 1))
+ {
+ operations.Add (PasteEditOperation.Substitute);
+ proposedIndex--;
+ finalIndex--;
+
+ continue;
+ }
+
+ if (finalIndex > 0 && costs [proposedIndex, finalIndex] == costs [proposedIndex, finalIndex - 1] + 1)
+ {
+ operations.Add (PasteEditOperation.Insert);
+ finalIndex--;
+
+ continue;
+ }
+
+ operations.Add (PasteEditOperation.Delete);
+ proposedIndex--;
+ }
+
+ operations.Reverse ();
+
+ return operations;
+ }
+
+ private enum PasteEditOperation
+ {
+ Insert,
+ Delete,
+ Substitute
+ }
+
+ private static bool AreEqual (List first, List second)
+ {
+ if (first.Count != second.Count)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < first.Count; i++)
+ {
+ if (first [i] != second [i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool ConsumesFinal (PasteEditOperation operation) => operation is PasteEditOperation.Insert or PasteEditOperation.Substitute;
+
+ private static bool ConsumesProposed (PasteEditOperation operation) => operation is PasteEditOperation.Delete or PasteEditOperation.Substitute;
+
+ private static bool IsWithinPasteRange (PasteEditOperation operation, int proposedIndex, int pasteStart, int pasteEnd)
+ {
+ if (operation == PasteEditOperation.Insert)
+ {
+ return proposedIndex > pasteStart && proposedIndex < pasteEnd;
+ }
+
+ return proposedIndex >= pasteStart && proposedIndex < pasteEnd;
+ }
+
/// Raises the event, enabling canceling the change or adjusting the text.
/// The event arguments.
/// if the event was cancelled or the text was adjusted by the event.
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
index 7af7ad1763..e66cc5d930 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
@@ -97,7 +97,9 @@ private void CreateCommandsAndBindings ()
AddCommand (Command.SelectAll, () => ProcessSelectAll ());
AddCommand (Command.CutToEndOfLine, () => CutToEndOfLine ());
AddCommand (Command.CutToStartOfLine, () => CutToStartOfLine ());
- AddCommand (Command.Paste, () => ProcessPaste ());
+
+ // Command.Paste uses the default View base handler — sanitization via OnSanitizingPaste,
+ // insertion via OnPaste. ProcessPaste's column-track reset happens inside OnPaste.
AddCommand (Command.Copy, () => ProcessCopy ());
AddCommand (Command.Cut, () => ProcessCut ());
AddCommand (Command.DeleteCharLeft, () => ProcessDeleteCharLeft ());
@@ -225,52 +227,12 @@ public bool Cut ()
}
/// Paste the clipboard contents into the current selected position.
- public bool Paste ()
- {
- if (_isReadOnly)
- {
- return true;
- }
-
- SetWrapModel ();
- string? contents = App?.Clipboard?.GetClipboardData ();
-
- if (_copyWithoutSelection && contents!.FirstOrDefault (x => x is '\n' or '\r') == 0)
- {
- List runeList = contents is null ? [] : Cell.ToCellList (contents);
- List currentLine = GetCurrentLine ();
- _historyText.Add ([[.. currentLine]], InsertionPoint);
- List> addedLine = [[.. currentLine], runeList];
- _historyText.Add ([.. addedLine], InsertionPoint, TextEditingLineStatus.Added);
- _model.AddLine (CurrentRow, runeList);
- SetNeedsDraw ();
- CurrentRow++;
- _historyText.Add ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Replaced);
-
- OnContentsChanged ();
- }
- else
- {
- if (IsSelecting)
- {
- ClearRegion ();
- }
-
- _copyWithoutSelection = false;
- InsertAllText (contents!, true);
-
- if (IsSelecting)
- {
- _historyText.ReplaceLast ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Original);
- }
- }
-
- UpdateWrapModel ();
- IsSelecting = false;
- DoNeededAction ();
-
- return true;
- }
+ ///
+ /// Routes through with a payload, so the
+ /// default handler reads from ,
+ /// sanitizes via , and inserts via .
+ ///
+ public bool Paste () => InvokeCommand (Command.Paste) is true;
private void AppendClipboard (string text) => App?.Clipboard?.SetClipboardData (App?.Clipboard?.GetClipboardData () + text);
@@ -920,18 +882,6 @@ private bool ProcessKillWordRight ()
return KillWordRight ();
}
- private bool ProcessPaste ()
- {
- ResetColumnTrack ();
-
- if (_isReadOnly)
- {
- return true;
- }
-
- return Paste ();
- }
-
private bool ProcessEnterKey (ICommandContext? commandContext)
{
ResetColumnTrack ();
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
index d12c8457ff..48b2da8839 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
@@ -252,6 +252,99 @@ public void InsertText (string toAdd)
}
}
+ ///
+ ///
+ /// Normalizes line endings (CR and CRLF → LF) so the text model sees a single newline form,
+ /// and strips C0/C1 control chars except tab and newline. Mirrors Windows Terminal's
+ /// FilterStringForPaste .
+ ///
+ protected override string OnSanitizingPaste (string raw)
+ {
+ StringBuilder sb = new (raw.Length);
+
+ for (var i = 0; i < raw.Length; i++)
+ {
+ char c = raw [i];
+
+ if (c == '\r')
+ {
+ sb.Append ('\n');
+
+ if (i + 1 < raw.Length && raw [i + 1] == '\n')
+ {
+ i++;
+ }
+
+ continue;
+ }
+
+ if (c == '\n' || c == '\t' || (c >= 0x20 && c < 0x7F) || c >= 0xA0)
+ {
+ sb.Append (c);
+ }
+ }
+
+ return sb.ToString ();
+ }
+
+ ///
+ ///
+ /// Returns even when so that Ctrl+V
+ /// does not bubble to a parent view that might also bind paste.
+ ///
+ protected override bool OnPaste (string text)
+ {
+ if (_isReadOnly)
+ {
+ return true;
+ }
+
+ ResetColumnTrack ();
+ SetWrapModel ();
+
+ // Preserves the legacy clipboard "Copy without selection → Paste inserts as new line above"
+ // behavior. _copyWithoutSelection is set only by this view's own Copy() command, so
+ // bracketed paste (which has no preceding Copy) naturally skips this branch.
+ if (!CurrentPasteUsesPayload && _copyWithoutSelection && text.FirstOrDefault (x => x is '\n' or '\r') == 0)
+ {
+ List runeList = Cell.ToCellList (text);
+ List currentLine = GetCurrentLine ();
+ _historyText.Add ([[.. currentLine]], InsertionPoint);
+ List> addedLine = [[.. currentLine], runeList];
+ _historyText.Add ([.. addedLine], InsertionPoint, TextEditingLineStatus.Added);
+ _model.AddLine (CurrentRow, runeList);
+ SetNeedsDraw ();
+ CurrentRow++;
+ _historyText.Add ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Replaced);
+
+ OnContentsChanged ();
+ }
+ else
+ {
+ if (IsSelecting)
+ {
+ ClearRegion ();
+ }
+
+ _copyWithoutSelection = false;
+ InsertAllText (text, true);
+
+ if (IsSelecting)
+ {
+ _historyText.ReplaceLast ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Original);
+ }
+ }
+
+ UpdateWrapModel ();
+ IsSelecting = false;
+ DoNeededAction ();
+
+ return true;
+ }
+
+ ///
+ protected override bool ShouldRaisePastedEvent (string text) => !_isReadOnly;
+
/// Replaces all the text based on the match case.
/// The text to find.
/// The match case setting.
diff --git a/Tests/StressTests/BracketedPasteDemoTests.cs b/Tests/StressTests/BracketedPasteDemoTests.cs
new file mode 100644
index 0000000000..b3d65285ae
--- /dev/null
+++ b/Tests/StressTests/BracketedPasteDemoTests.cs
@@ -0,0 +1,44 @@
+using UICatalog.Scenarios;
+
+namespace StressTests;
+
+public class BracketedPasteDemoTests
+{
+ // Copilot
+ [Fact]
+ public void BracketedPasteDemo_Categories_IncludeTextAndFormatting_NotInput ()
+ {
+ BracketedPasteDemo scenario = new ();
+
+ List categories = scenario.GetCategories ();
+
+ Assert.Contains ("Text and Formatting", categories);
+ Assert.DoesNotContain ("Input", categories);
+ }
+
+ // Copilot
+ [Fact]
+ public void FormatPasteLogEntry_IndicatesBracketedPasteEvent ()
+ {
+ string message = BracketedPasteDemo.FormatPasteLogEntry (1, "abc");
+
+ Assert.Equal ("[1] Bracketed paste event: 3 chars: abc", message);
+ }
+
+ // Copilot
+ [Fact]
+ public void CreateHintLabel_InNarrowWindow_WrapsToMultipleLines ()
+ {
+ Window window = new ()
+ {
+ Width = 20,
+ Height = 10
+ };
+ Label hint = BracketedPasteDemo.CreateHintLabel ();
+
+ window.Add (hint);
+ window.Layout ();
+
+ Assert.True (hint.Frame.Height > 1);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiBracketedPasteTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiBracketedPasteTests.cs
new file mode 100644
index 0000000000..93b9dd88d2
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/AnsiBracketedPasteTests.cs
@@ -0,0 +1,315 @@
+namespace DriverTests.AnsiHandling;
+
+// Claude - Opus 4.7
+[Collection ("Driver Tests")]
+public class AnsiBracketedPasteTests
+{
+ private readonly AnsiResponseParser _parser = new (new SystemTimeProvider ());
+
+ [Fact]
+ public void Paste_FullSequence_RaisesEventWithStrippedMarkers ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string output = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}hello world{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal ("hello world", captured);
+ Assert.Equal (string.Empty, output);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Fact]
+ public void Paste_FullSequence_RaisesEventOutsideParserLock ()
+ {
+ object lockState = typeof (AnsiResponseParserBase)
+ .GetField ("_lockState", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
+ .GetValue (_parser)!;
+ bool? lockHeld = null;
+ _parser.Paste += (_, _) => lockHeld = Monitor.IsEntered (lockState);
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}hello world{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.False (lockHeld);
+ }
+
+ [Fact]
+ public void Paste_SurroundedByNormalInput_OnlyPastedTextIsDelivered ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string output = _parser.ProcessInput ($"before{EscSeqUtils.CSI_BracketedPasteStart}PASTE{EscSeqUtils.CSI_BracketedPasteEnd}after");
+
+ Assert.Equal ("PASTE", captured);
+ Assert.Equal ("beforeafter", output);
+ }
+
+ [Fact]
+ public void Paste_SplitAcrossProcessInputCalls_AccumulatesAndDelivers ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ // Start marker arrives by itself
+ string output1 = _parser.ProcessInput (EscSeqUtils.CSI_BracketedPasteStart);
+ Assert.Null (captured);
+ Assert.Equal (string.Empty, output1);
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, _parser.State);
+
+ // Body arrives in the middle
+ string output2 = _parser.ProcessInput ("multi-line\npaste\rbody");
+ Assert.Null (captured);
+ Assert.Equal (string.Empty, output2);
+
+ // End marker arrives by itself
+ string output3 = _parser.ProcessInput (EscSeqUtils.CSI_BracketedPasteEnd);
+ Assert.Equal ("multi-line\npaste\rbody", captured);
+ Assert.Equal (string.Empty, output3);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Fact]
+ public void Paste_ContainingControlChars_PreservesBytesVerbatim ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string payload = "tab\there\nnewline\rcr";
+
+ string output = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{payload}{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal (payload, captured);
+ Assert.Equal (string.Empty, output);
+ }
+
+ [Fact]
+ public void Paste_ContainingPartialEndMarkerPrefix_DoesNotTerminateEarly ()
+ {
+ // Payload contains "ESC[201" without the trailing tilde — must NOT be treated as the end
+ // marker. The real end marker arrives later.
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string trickyPayload = "\u001b[201"; // looks like the start of the end marker but missing '~'
+
+ string output = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{trickyPayload}rest{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal ("\u001b[201rest", captured);
+ Assert.Equal (string.Empty, output);
+ }
+
+ [Fact]
+ public void Paste_EmptyPayload_RaisesEventWithEmptyString ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string output = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal (string.Empty, captured);
+ Assert.Equal (string.Empty, output);
+ }
+
+ [Fact]
+ public void Paste_OversizedPayload_TruncatesAndWaitsForEndMarker ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ // Build a payload larger than the max paste length so the parser must flush mid-paste.
+ string longBody = new ('x', AnsiResponseParserBase.MaxBracketedPasteLength + 100);
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{longBody}");
+
+ Assert.NotNull (captured);
+ Assert.Equal (AnsiResponseParserBase.MaxBracketedPasteLength, captured!.Length);
+ Assert.Equal (AnsiResponseParserState.DiscardingBracketedPasteRemainder, _parser.State);
+ }
+
+ [Fact]
+ public void Paste_OversizedPayload_DiscardsRemainderUntilEndMarker ()
+ {
+ List captured = [];
+ _parser.Paste += (_, text) => captured.Add (text);
+
+ string payload = new ('x', AnsiResponseParserBase.MaxBracketedPasteLength);
+ string expectedPayload = new ('x', AnsiResponseParserBase.MaxBracketedPasteLength);
+
+ string firstOutput = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{payload}");
+ string secondOutput = _parser.ProcessInput ($"tail{EscSeqUtils.CSI_BracketedPasteEnd}typed");
+
+ Assert.Equal ([expectedPayload], captured);
+ Assert.Equal (string.Empty, firstOutput);
+ Assert.Equal ("typed", secondOutput);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Theory]
+ [InlineData (1)]
+ [InlineData (2)]
+ [InlineData (3)]
+ [InlineData (4)]
+ [InlineData (5)]
+ public void Paste_EndMarkerStraddlingSizeBoundary_DoesNotLeakMarkerBytesOrStayDiscarding (int markerBytesInBuffer)
+ {
+ List captured = [];
+ _parser.Paste += (_, text) => captured.Add (text);
+
+ string endMarker = EscSeqUtils.CSI_BracketedPasteEnd;
+ string body = new ('x', AnsiResponseParserBase.MaxBracketedPasteLength - markerBytesInBuffer);
+ string partialMarker = endMarker [..markerBytesInBuffer];
+ string remainingMarker = endMarker [markerBytesInBuffer..];
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{body}{partialMarker}");
+
+ Assert.Equal ([body], captured);
+ Assert.Equal (AnsiResponseParserState.DiscardingBracketedPasteRemainder, _parser.State);
+
+ string output = _parser.ProcessInput ($"{remainingMarker}typed");
+
+ Assert.Equal ("typed", output);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Fact]
+ public void Paste_WithoutEnableEvent_StillBuffersUntilEndMarker ()
+ {
+ // No subscriber on Paste — parser still consumes the markers without leaking to output.
+ string output = _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}body{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal (string.Empty, output);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Theory]
+ [InlineData (1)]
+ [InlineData (2)]
+ [InlineData (3)]
+ [InlineData (4)]
+ [InlineData (5)]
+ [InlineData (6)]
+ public void Paste_StartMarkerSplitAtEveryByteBoundary_StillDetected (int splitAt)
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string startMarker = EscSeqUtils.CSI_BracketedPasteStart;
+ string firstChunk = startMarker [..splitAt];
+ string secondChunk = startMarker [splitAt..] + "PAYLOAD" + EscSeqUtils.CSI_BracketedPasteEnd;
+
+ _parser.ProcessInput (firstChunk);
+ _parser.ProcessInput (secondChunk);
+
+ Assert.Equal ("PAYLOAD", captured);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Theory]
+ [InlineData (1)]
+ [InlineData (2)]
+ [InlineData (3)]
+ [InlineData (4)]
+ [InlineData (5)]
+ [InlineData (6)]
+ public void Paste_EndMarkerSplitAtEveryByteBoundary_StillDetected (int splitAt)
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ string endMarker = EscSeqUtils.CSI_BracketedPasteEnd;
+
+ _parser.ProcessInput (EscSeqUtils.CSI_BracketedPasteStart + "body");
+ _parser.ProcessInput (endMarker [..splitAt]);
+ _parser.ProcessInput (endMarker [splitAt..]);
+
+ Assert.Equal ("body", captured);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+
+ [Fact]
+ public void Paste_BodyEndsWithPartialEndMarkerAcrossCalls_DoesNotTerminateEarly ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ // First chunk legitimately contains "ESC[20" at the end (a near-end-marker prefix).
+ // Second chunk supplies a different char so the prefix is rejected, then the real body
+ // and end marker.
+ _parser.ProcessInput (EscSeqUtils.CSI_BracketedPasteStart + "head\u001b[20");
+ _parser.ProcessInput ($"X-tail{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal ("head\u001b[20X-tail", captured);
+ }
+
+ [Fact]
+ public void Paste_TwoConsecutivePastes_BothDelivered ()
+ {
+ List captured = [];
+ _parser.Paste += (_, text) => captured.Add (text);
+
+ string input = $"{EscSeqUtils.CSI_BracketedPasteStart}A{EscSeqUtils.CSI_BracketedPasteEnd}"
+ + $"between"
+ + $"{EscSeqUtils.CSI_BracketedPasteStart}B{EscSeqUtils.CSI_BracketedPasteEnd}";
+
+ string output = _parser.ProcessInput (input);
+
+ Assert.Equal (["A", "B"], captured);
+ Assert.Equal ("between", output);
+ }
+
+ [Fact]
+ public void Paste_BodyContainsLiteralStartMarker_AppendedAsContent ()
+ {
+ // A well-behaved terminal won't emit a nested start marker, but a hostile or buggy one might.
+ // We treat any bytes between the outer start and end markers as paste content.
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}before{EscSeqUtils.CSI_BracketedPasteStart}after{EscSeqUtils.CSI_BracketedPasteEnd}");
+
+ Assert.Equal ($"before{EscSeqUtils.CSI_BracketedPasteStart}after", captured);
+ }
+
+ [Fact]
+ public void Paste_ResetMidPaste_DiscardsBufferAndReturnsToNormal ()
+ {
+ string? captured = null;
+ _parser.Paste += (_, text) => captured = text;
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}partial");
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, _parser.State);
+
+ // Release/Reset is the parser's escape hatch for stale escape sequences. While in
+ // bracketed-paste mode it must not silently drop the buffer — but the documented
+ // behavior here is that Release returns to Normal. Verify state, and verify that
+ // input flow resumes cleanly afterward.
+ _parser.Release ();
+
+ Assert.Null (captured);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+
+ string output = _parser.ProcessInput ("typed");
+ Assert.Equal ("typed", output);
+ }
+
+ [Fact]
+ public void FlushStaleBracketedPaste_WhenDiscarding_ReturnsToNormalWithoutAnotherPasteEvent ()
+ {
+ List captured = [];
+ _parser.Paste += (_, text) => captured.Add (text);
+
+ string payload = new ('x', AnsiResponseParserBase.MaxBracketedPasteLength);
+
+ _parser.ProcessInput ($"{EscSeqUtils.CSI_BracketedPasteStart}{payload}");
+
+ Assert.Equal (AnsiResponseParserState.DiscardingBracketedPasteRemainder, _parser.State);
+ Assert.Single (captured);
+
+ bool flushed = _parser.FlushStaleBracketedPaste ();
+
+ Assert.True (flushed);
+ Assert.Single (captured);
+ Assert.Equal (AnsiResponseParserState.Normal, _parser.State);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/EscSeqUtilsTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/EscSeqUtilsTests.cs
index 42c1c8d433..433ec8b504 100644
--- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/EscSeqUtilsTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/EscSeqUtilsTests.cs
@@ -22,6 +22,12 @@ public void Defaults_Values ()
Assert.Equal ("\x1b[?1003l\x1b[?1015l\u001b[?1006l", EscSeqUtils.CSI_DisableMouseEvents);
Assert.Equal ($"{EscSeqUtils.CSI}6n", EscSeqUtils.CSI_RequestCursorPositionReport.Request);
Assert.Equal ("R", EscSeqUtils.CSI_RequestCursorPositionReport.Terminator);
+
+ // Claude - Opus 4.7
+ Assert.Equal ("\x1b[?2004h", EscSeqUtils.CSI_EnableBracketedPaste);
+ Assert.Equal ("\x1b[?2004l", EscSeqUtils.CSI_DisableBracketedPaste);
+ Assert.Equal ("\x1b[200~", EscSeqUtils.CSI_BracketedPasteStart);
+ Assert.Equal ("\x1b[201~", EscSeqUtils.CSI_BracketedPasteEnd);
}
[Fact]
diff --git a/Tests/UnitTestsParallelizable/Input/InputProcessorImplTests.cs b/Tests/UnitTestsParallelizable/Input/InputProcessorImplTests.cs
index 0b4b54f324..f342b9efcd 100644
--- a/Tests/UnitTestsParallelizable/Input/InputProcessorImplTests.cs
+++ b/Tests/UnitTestsParallelizable/Input/InputProcessorImplTests.cs
@@ -74,6 +74,107 @@ public async Task ProcessQueue_DoesNotReleaseEscape_BeforeTimeout ()
#endregion
+ #region Bracketed Paste Stale Flush
+
+ // Claude - Opus 4.7
+ [Fact]
+ public void FlushStaleBracketedPaste_WhenInPasteState_DispatchesPartialBufferAndResetsToNormal ()
+ {
+ AnsiResponseParser parser = new (new SystemTimeProvider ());
+
+ string? captured = null;
+ parser.Paste += (_, text) => captured = text;
+
+ parser.ProcessInput (EscSeqUtils.CSI_BracketedPasteStart + "stranded");
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, parser.State);
+ Assert.Null (captured);
+
+ bool flushed = parser.FlushStaleBracketedPaste ();
+
+ Assert.True (flushed);
+ Assert.Equal ("stranded", captured);
+ Assert.Equal (AnsiResponseParserState.Normal, parser.State);
+
+ // Subsequent input flows normally.
+ string output = parser.ProcessInput ("typed");
+ Assert.Equal ("typed", output);
+ }
+
+ // Claude - Opus 4.7
+ [Fact]
+ public void FlushStaleBracketedPaste_WhenNotInPasteState_IsNoOp ()
+ {
+ AnsiResponseParser parser = new (new SystemTimeProvider ());
+
+ var fired = false;
+ parser.Paste += (_, _) => fired = true;
+
+ bool flushed = parser.FlushStaleBracketedPaste ();
+
+ Assert.False (flushed);
+ Assert.False (fired);
+ }
+
+ [Fact]
+ public void ProcessQueue_BracketedPaste_DoesNotFlushWhileBytesContinueArriving ()
+ {
+ VirtualTimeProvider timeProvider = new ();
+ ConcurrentQueue queue = new ();
+ TestInputProcessor processor = new (queue, true, timeProvider);
+
+ string? captured = null;
+ processor.Paste += (_, text) => captured = text;
+
+ TestInputQueueHelper.EnqueueString (queue, EscSeqUtils.CSI_BracketedPasteStart + "ab");
+ processor.ProcessQueue ();
+
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, processor.Parser.State);
+ Assert.Null (captured);
+
+ timeProvider.Advance (TimeSpan.FromSeconds (4));
+ TestInputQueueHelper.EnqueueString (queue, "cd");
+ processor.ProcessQueue ();
+
+ Assert.Null (captured);
+
+ timeProvider.Advance (TimeSpan.FromSeconds (4));
+ processor.ProcessQueue ();
+
+ Assert.Null (captured);
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, processor.Parser.State);
+
+ TestInputQueueHelper.EnqueueString (queue, EscSeqUtils.CSI_BracketedPasteEnd);
+ processor.ProcessQueue ();
+
+ Assert.Equal ("abcd", captured);
+ Assert.Equal (AnsiResponseParserState.Normal, processor.Parser.State);
+ }
+
+ [Fact]
+ public void ProcessQueue_BracketedPaste_FlushesAfterIdleTimeout ()
+ {
+ VirtualTimeProvider timeProvider = new ();
+ ConcurrentQueue queue = new ();
+ TestInputProcessor processor = new (queue, true, timeProvider);
+
+ string? captured = null;
+ processor.Paste += (_, text) => captured = text;
+
+ TestInputQueueHelper.EnqueueString (queue, EscSeqUtils.CSI_BracketedPasteStart + "stranded");
+ processor.ProcessQueue ();
+
+ Assert.Equal (AnsiResponseParserState.InBracketedPaste, processor.Parser.State);
+ Assert.Null (captured);
+
+ timeProvider.Advance (TimeSpan.FromSeconds (6));
+ processor.ProcessQueue ();
+
+ Assert.Equal ("stranded", captured);
+ Assert.Equal (AnsiResponseParserState.Normal, processor.Parser.State);
+ }
+
+ #endregion
+
#region Surrogate Pair Tests
// CoPilot: claude-3-7-sonnet-20250219
@@ -606,7 +707,7 @@ internal class TestInputProcessor : InputProcessorImpl
{
private readonly bool _useParser;
- public TestInputProcessor (ConcurrentQueue inputBuffer, bool useParser = false) : base (inputBuffer, new TestKeyConverter ()) =>
+ public TestInputProcessor (ConcurrentQueue inputBuffer, bool useParser = false, ITimeProvider? timeProvider = null) : base (inputBuffer, new TestKeyConverter (), timeProvider) =>
_useParser = useParser;
protected override void Process (ConsoleKeyInfo input)
@@ -624,6 +725,17 @@ protected override void Process (ConsoleKeyInfo input)
}
}
+internal static class TestInputQueueHelper
+{
+ public static void EnqueueString (ConcurrentQueue queue, string text)
+ {
+ foreach (char c in text)
+ {
+ queue.Enqueue (new ConsoleKeyInfo (c, 0, false, false, false));
+ }
+ }
+}
+
///
/// Test implementation of for testing purposes.
///
diff --git a/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs b/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs
new file mode 100644
index 0000000000..6981f2493d
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/ViewBase/ViewPasteTests.cs
@@ -0,0 +1,287 @@
+namespace ViewBaseTests;
+
+// Claude - Opus 4.7
+public class ViewPasteTests
+{
+ private static bool? InvokePaste (View view, string payload)
+ {
+ CommandContext ctx = new (Command.Paste, new WeakReference (view), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ return view.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload (payload)));
+ }
+
+ [Fact]
+ public void CommandPaste_RaisesPastingAndPasted_WhenConsumed ()
+ {
+ TextField field = new () { Text = string.Empty };
+
+ var raiseOrder = new List ();
+ field.Pasting += (_, _) => raiseOrder.Add ("pasting");
+ field.Pasted += (_, _) => raiseOrder.Add ("pasted");
+
+ bool? handled = InvokePaste (field, "hello");
+
+ Assert.True (handled);
+ Assert.Equal (["pasting", "pasted"], raiseOrder);
+ Assert.Equal ("hello", field.Text);
+ }
+
+ [Fact]
+ public void Pasting_Cancelled_DoesNotInsertOrRaisePasted ()
+ {
+ TextField field = new () { Text = "x" };
+
+ var pastedFired = false;
+ field.Pasting += (_, args) => args.Handled = true;
+ field.Pasted += (_, _) => pastedFired = true;
+
+ bool? handled = InvokePaste (field, "yz");
+
+ Assert.True (handled);
+ Assert.False (pastedFired);
+ Assert.Equal ("x", field.Text);
+ }
+
+ [Fact]
+ public void Pasting_RewritesText_BeforeInsertion ()
+ {
+ TextField field = new () { Text = string.Empty };
+
+ field.Pasting += (_, args) => args.Text = args.Text.ToUpperInvariant ();
+
+ InvokePaste (field, "abc");
+
+ Assert.Equal ("ABC", field.Text);
+ }
+
+ [Fact]
+ public void View_OnPaste_Default_ReturnsFalse_AndPastedNotRaised ()
+ {
+ View view = new ();
+ var pastedFired = false;
+ view.Pasted += (_, _) => pastedFired = true;
+
+ bool? handled = InvokePaste (view, "hello");
+
+ // Plain View's OnPaste returns false → handler returns false, Pasted not raised.
+ Assert.False (handled);
+ Assert.False (pastedFired);
+ }
+
+ [Fact]
+ public void DisabledView_DoesNotPaste ()
+ {
+ TextField field = new () { Text = string.Empty, Enabled = false };
+
+ bool? handled = InvokePaste (field, "x");
+
+ Assert.False (handled);
+ Assert.Equal (string.Empty, field.Text);
+ }
+
+ [Fact]
+ public void TextField_Paste_InsertsTextAtCursor ()
+ {
+ TextField field = new () { Text = "abc", ReadOnly = false };
+ field.MoveEnd ();
+
+ bool? handled = InvokePaste (field, "XYZ");
+
+ Assert.True (handled);
+ Assert.Equal ("abcXYZ", field.Text);
+ }
+
+ [Fact]
+ public void TextField_Paste_ReadOnly_DoesNotInsert ()
+ {
+ TextField field = new () { Text = "abc", ReadOnly = true };
+
+ bool? handled = InvokePaste (field, "XYZ");
+
+ // ReadOnly views consume the paste (return true) so Ctrl+V does not bubble, but the text
+ // model is not modified.
+ Assert.True (handled);
+ Assert.Equal ("abc", field.Text);
+ }
+
+ [Fact]
+ public void TextField_Paste_ReadOnly_DoesNotRaisePasted ()
+ {
+ TextField field = new () { Text = "abc", ReadOnly = true };
+ var pastedFired = false;
+ field.Pasted += (_, _) => pastedFired = true;
+
+ bool? handled = InvokePaste (field, "XYZ");
+
+ Assert.True (handled);
+ Assert.False (pastedFired);
+ }
+
+ [Fact]
+ public void TextField_Paste_TakesFirstLineOnly ()
+ {
+ TextField field = new () { Text = string.Empty };
+
+ bool? handled = InvokePaste (field, "first\nsecond\rthird");
+
+ Assert.True (handled);
+ Assert.Equal ("first", field.Text);
+ }
+
+ [Fact]
+ public void TextField_Paste_StripsControlCharsIncludingEscape ()
+ {
+ TextField field = new () { Text = string.Empty };
+
+ // ESC[31m color sequence and a literal bell — both must be stripped.
+ bool? handled = InvokePaste (field, "ab[31mcd");
+
+ Assert.True (handled);
+ Assert.Equal ("ab[31mcd", field.Text);
+ }
+
+ [Fact]
+ public void TextField_Paste_AllControlChars_DoesNotInsert ()
+ {
+ TextField field = new () { Text = "x" };
+
+ bool? handled = InvokePaste (field, "");
+
+ Assert.False (handled);
+ Assert.Equal ("x", field.Text);
+ }
+
+ [Fact]
+ public void TextView_Paste_InsertsText ()
+ {
+ TextView textView = new () { Text = "abc", ReadOnly = false };
+ textView.MoveEnd ();
+
+ bool? handled = InvokePaste (textView, "XYZ");
+
+ Assert.True (handled);
+ Assert.Contains ("XYZ", textView.Text);
+ }
+
+ [Fact]
+ public void TextView_Paste_ReadOnly_DoesNotInsert ()
+ {
+ TextView textView = new () { Text = "abc", ReadOnly = true };
+
+ bool? handled = InvokePaste (textView, "XYZ");
+
+ // ReadOnly views consume the paste (return true) so Ctrl+V does not bubble, but the text
+ // model is not modified.
+ Assert.True (handled);
+ Assert.Equal ("abc", textView.Text);
+ }
+
+ [Fact]
+ public void TextView_Paste_ReadOnly_DoesNotRaisePasted ()
+ {
+ TextView textView = new () { Text = "abc", ReadOnly = true };
+ var pastedFired = false;
+ textView.Pasted += (_, _) => pastedFired = true;
+
+ bool? handled = InvokePaste (textView, "XYZ");
+
+ Assert.True (handled);
+ Assert.False (pastedFired);
+ }
+
+ [Fact]
+ public void TextView_Paste_NormalizesCarriageReturnsToLogicalLineBreaks ()
+ {
+ TextView textView = new () { Text = string.Empty };
+
+ // Mix of CR (terminal default), CRLF, and bare LF — all become logical line breaks.
+ bool? handled = InvokePaste (textView, "a\rb\r\nc\nd");
+
+ Assert.True (handled);
+ Assert.Equal ($"a{Environment.NewLine}b{Environment.NewLine}c{Environment.NewLine}d", textView.Text);
+ }
+
+ [Fact]
+ public void TextView_Paste_StripsEscapeAndOtherControlChars ()
+ {
+ TextView textView = new () { Text = string.Empty };
+
+ bool? handled = InvokePaste (textView, "a[31mbc\td");
+
+ Assert.True (handled);
+ Assert.Equal ("a[31mbc\td", textView.Text);
+ }
+
+ [Fact]
+ public void TextView_BracketedPaste_Ignores_CopyWithoutSelection_Mode ()
+ {
+ TextView textView = new () { Text = "abc" };
+ textView.MoveEnd ();
+
+ Assert.True (textView.Copy ());
+
+ bool? handled = InvokePaste (textView, "Z");
+
+ Assert.True (handled);
+ Assert.Equal ("abcZ", textView.Text);
+ }
+
+ [Fact]
+ public void EmptyPayload_DoesNotRaisePastingOrPasted ()
+ {
+ TextField field = new () { Text = string.Empty };
+
+ var anyFired = false;
+ field.Pasting += (_, _) => anyFired = true;
+ field.Pasted += (_, _) => anyFired = true;
+
+ bool? handled = InvokePaste (field, string.Empty);
+
+ Assert.False (handled);
+ Assert.False (anyFired);
+ }
+
+ [Fact]
+ public void ApplicationPaste_Handled_DoesNotDispatchToFocusedView ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ using Runnable runnable = new ();
+ TextField field = new () { Text = string.Empty };
+ var anyFired = false;
+ field.Pasting += (_, _) => anyFired = true;
+ field.Pasted += (_, _) => anyFired = true;
+ runnable.Add (field);
+ app.Begin (runnable);
+ field.SetFocus ();
+ app.Paste += (_, args) => args.Handled = true;
+
+ bool handled = app.RaisePasteEvent ("hello");
+
+ Assert.True (handled);
+ Assert.Equal (string.Empty, field.Text);
+ Assert.False (anyFired);
+ }
+
+ [Fact]
+ public void MenuInitiatedPaste_UsesClipboardText_NotMenuTitle ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.Clipboard = new FakeClipboard ();
+ using Runnable runnable = new ();
+ TextView textView = new () { Text = string.Empty, Width = 20, Height = 5 };
+ runnable.Add (textView);
+ app.Begin (runnable);
+ app.Driver.Clipboard.SetClipboardData ("Hello ");
+ MenuItem menuItem = new (textView, Command.Paste);
+
+ bool? handled = menuItem.InvokeCommand (Command.Activate);
+
+ Assert.False (handled);
+ Assert.Equal ("Hello ", textView.Text);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs
index f77b1147e8..f9c8d429ce 100644
--- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs
@@ -1456,6 +1456,286 @@ public void UnifiedKeyBindings_Undo_Redo_Paste_DeleteAll ()
Assert.Equal ("", tf.Text);
}
+ // Copilot
+ [Fact]
+ public void Paste_Uses_Clipboard_Text ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.Clipboard = new FakeClipboard ();
+ using Runnable runnable = new ();
+
+ TextField tf = new () { Width = 40, Text = string.Empty };
+ runnable.Add (tf);
+ app.Begin (runnable);
+
+ app.Driver.Clipboard!.SetClipboardData ("Hello ");
+
+ bool result = tf.Paste ();
+
+ Assert.True (result);
+ Assert.Equal ("Hello ", tf.Text);
+ }
+
+ // Copilot
+ [Fact]
+ public void CtrlV_Pastes_From_Clipboard ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.Clipboard = new FakeClipboard ();
+ using Runnable runnable = new ();
+
+ TextField tf = new () { Width = 40, Text = string.Empty };
+ runnable.Add (tf);
+ app.Begin (runnable);
+ tf.SetFocus ();
+
+ app.Driver.Clipboard!.SetClipboardData ("Hello ");
+
+ bool? result = tf.NewKeyDownEvent (Key.V.WithCtrl);
+
+ Assert.True (result);
+ Assert.Equal ("Hello ", tf.Text);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_CancelledByValueChanging_DoesNotRaisePasted_OrMoveCursor ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.Clipboard = new FakeClipboard ();
+ using Runnable runnable = new ();
+
+ TextField tf = new () { Width = 40, Text = "abc" };
+ app.Begin (runnable);
+ runnable.Add (tf);
+ tf.InsertionPoint = 1;
+ tf.SelectedStart = -1;
+
+ int pastedCount = 0;
+ tf.Pasted += (_, _) => pastedCount++;
+ tf.ValueChanging += (_, e) => e.Handled = true;
+
+ app.Driver.Clipboard!.SetClipboardData ("ZZ");
+
+ bool result = tf.Paste ();
+
+ Assert.True (result);
+ Assert.Equal ("abc", tf.Text);
+ Assert.Equal (1, tf.InsertionPoint);
+ Assert.Equal (0, pastedCount);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_RewrittenByTextChanging_Raises_Pasted_With_ActualInsertedText ()
+ {
+ using IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+ app.Driver!.Clipboard = new FakeClipboard ();
+ using Runnable runnable = new ();
+
+ TextField tf = new () { Width = 40, Text = string.Empty };
+ runnable.Add (tf);
+ app.Begin (runnable);
+
+ string? pastedText = null;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+ tf.TextChanging += (_, e) => e.Result = e.Result!.ToUpperInvariant ();
+
+ app.Driver.Clipboard!.SetClipboardData ("Hello");
+
+ bool result = tf.Paste ();
+
+ Assert.True (result);
+ Assert.Equal ("HELLO", tf.Text);
+ Assert.Equal ("HELLO", pastedText);
+ Assert.Equal (5, tf.InsertionPoint);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_RewrittenByTextChanging_With_Existing_Text_Raises_Only_Inserted_Text ()
+ {
+ TextField tf = new () { Width = 40, Text = "abc" };
+ tf.InsertionPoint = 1;
+ tf.SelectedStart = -1;
+
+ string? pastedText = null;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+ tf.TextChanging += (_, e) => e.Result = e.Result!.ToUpperInvariant ();
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("zz")));
+
+ Assert.True (result);
+ Assert.Equal ("AZZBC", tf.Text);
+ Assert.Equal ("ZZ", pastedText);
+ Assert.Equal (3, tf.InsertionPoint);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_RewrittenByTextChanging_With_AddedPrefixAndSuffix_Raises_Only_Inserted_Text ()
+ {
+ TextField tf = new () { Width = 40, Text = "abcd" };
+ tf.InsertionPoint = 2;
+ tf.SelectedStart = -1;
+
+ string? pastedText = null;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+ tf.TextChanging += (_, e) => e.Result = $"[{e.Result}]";
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("X")));
+
+ Assert.True (result);
+ Assert.Equal ("[abXcd]", tf.Text);
+ Assert.Equal ("X", pastedText);
+ Assert.Equal (4, tf.InsertionPoint);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_RewrittenByTextChanging_In_Empty_Field_Excludes_Boundary_Wrappers ()
+ {
+ TextField tf = new () { Width = 40, Text = string.Empty };
+ tf.SelectedStart = -1;
+
+ string? pastedText = null;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+ tf.TextChanging += (_, e) => e.Result = $"[{e.Result}]";
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("Hello")));
+
+ Assert.True (result);
+ Assert.Equal ("[Hello]", tf.Text);
+ Assert.Equal ("Hello", pastedText);
+ Assert.Equal (6, tf.InsertionPoint);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_Replacing_Selection_With_No_Inserted_Text_Moves_Cursor_To_Selection_Start ()
+ {
+ TextField tf = new () { Width = 40, Text = "abcd" };
+ tf.InsertionPoint = 4;
+ tf.SelectedStart = 2;
+
+ int pastedCount = 0;
+ tf.Pasted += (_, _) => pastedCount++;
+ tf.TextChanging += (_, e) => e.Result = "ab";
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("X")));
+
+ Assert.True (result);
+ Assert.Equal ("ab", tf.Text);
+ Assert.Equal (2, tf.InsertionPoint);
+ Assert.Equal (0, pastedCount);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_RewrittenWithoutLengthChange_Does_Not_Allocate_Quadratic_Memory ()
+ {
+ const int pasteLength = 3000;
+ const long allocationBudget = 10_000_000;
+
+ TextField tf = new () { Width = 4000, Text = string.Empty };
+ string pasted = new ('a', pasteLength);
+ tf.TextChanging += (_, e) => e.Result = e.Result!.ToUpperInvariant ();
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ GC.Collect ();
+ GC.WaitForPendingFinalizers ();
+ GC.Collect ();
+
+ long before = GC.GetAllocatedBytesForCurrentThread ();
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload (pasted)));
+
+ long allocated = GC.GetAllocatedBytesForCurrentThread () - before;
+
+ Assert.True (result);
+ Assert.Equal (pasted.ToUpperInvariant (), tf.Text);
+ Assert.Equal (pasteLength, tf.InsertionPoint);
+ Assert.True (allocated < allocationBudget, $"Allocated {allocated:N0} bytes.");
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_Pasting_Event_Text_Matches_TextField_Insertable_Text ()
+ {
+ TextField tf = new () { Width = 40, Text = string.Empty };
+
+ string? pastingText = null;
+ string? pastedText = null;
+ tf.Pasting += (_, e) => pastingText = e.Text;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("A\tB")));
+
+ Assert.True (result);
+ Assert.Equal ("AB", tf.Text);
+ Assert.Equal ("AB", pastingText);
+ Assert.Equal ("AB", pastedText);
+ }
+
+ // Copilot
+ [Fact]
+ public void Paste_Replacing_Selection_With_Identical_Text_Clears_Selection_And_Raises_Pasted ()
+ {
+ TextField tf = new () { Width = 40, Text = "abcd" };
+ tf.InsertionPoint = 3;
+ tf.SelectedStart = 1;
+
+ string? pastedText = null;
+ tf.Pasted += (_, e) => pastedText = e.Text;
+
+ CommandContext ctx = new (Command.Paste, new WeakReference (tf), binding: null)
+ {
+ Routing = CommandRouting.BubblingUp
+ };
+
+ bool? result = tf.InvokeCommand (Command.Paste, ctx.WithValue (new PastePayload ("bc")));
+
+ Assert.True (result);
+ Assert.Equal ("abcd", tf.Text);
+ Assert.Equal (3, tf.InsertionPoint);
+ Assert.Equal (0, tf.SelectedLength);
+ Assert.Null (tf.SelectedText);
+ Assert.Equal ("bc", pastedText);
+ }
+
// Copilot
[Fact]
public void UnifiedKeyBindings_NonWindows_Undo_Redo ()
diff --git a/docfx/docs/bracketed-paste.md b/docfx/docs/bracketed-paste.md
new file mode 100644
index 0000000000..c9ebea63b4
--- /dev/null
+++ b/docfx/docs/bracketed-paste.md
@@ -0,0 +1,121 @@
+# Bracketed Paste
+
+Terminal.Gui supports the ANSI [bracketed paste mode](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Functions-using-CSI-_-ordered-by-the-final-character-s) (`DECSET 2004`). When the terminal has bracketed paste enabled, the terminal wraps clipboard pastes with the marker pair `ESC[200~` and `ESC[201~`. This lets the application distinguish a paste from typed input — useful for performance (a 10 KB paste arrives as one event instead of 10,000 keystrokes) and for security (the application does not have to interpret pasted text as commands or shortcuts).
+
+Bracketed paste mode is enabled automatically by the driver when the application initializes and disabled on shutdown. Applications do not need to opt in.
+
+## Architecture: bracketed paste shares the `Command.Paste` pipeline
+
+Bracketed paste and keyboard-driven paste (`Ctrl+V`) route through the same command pipeline:
+
+```
+driver bytes ──► AnsiResponseParser ──► IApplication.Paste (event, raw payload)
+ │
+ ▼
+ focused view.InvokeCommand (Command.Paste, ctx.WithValue (PastePayload (payload)))
+ │
+ ▼
+ ┌───────────────── default Command.Paste handler ─────────────────┐
+ │ payload = ctx.Value is PastePayload p ? p.Text : clipboard │
+ │ sanitized = OnSanitizingPaste (payload) │
+ │ raise Pasting (cancellable, mutable Text) │
+ │ if not cancelled: consumed = OnPaste (sanitized) │
+ │ if inserted: raise Pasted │
+ └─────────────────────────────────────────────────────────────────────┘
+```
+
+The same handler serves both bracketed paste (payload travels in a dedicated command-context paste payload) and keyboard `Ctrl+V` (no payload → clipboard fallback). There is no parallel paste dispatch path.
+
+## Receiving paste events
+
+There are three places to hook in, in order of dispatch:
+
+### 1. `Application.Paste` — raw, app-wide
+
+```csharp
+app.Paste += (_, args) =>
+ {
+ myLog.AppendLine ($"Pasted {args.Text.Length} characters");
+ // Set args.Handled = true to stop further dispatch.
+ };
+```
+
+Subscribers see the raw terminal-delivered payload before any sanitization. Cancelling here prevents the paste from reaching any view.
+
+### 2. `View.Pasting` — sanitized, cancellable, mutable
+
+```csharp
+field.Pasting += (_, args) =>
+ {
+ // args.Text is already sanitized. Rewrite it, or cancel.
+ args.Text = args.Text.Trim ();
+ // args.Handled = true // cancel insertion
+ };
+```
+
+Raised after the default handler resolves the payload and calls `OnSanitizingPaste`. Subscribers may rewrite `args.Text` to alter what gets inserted, or set `Handled` to cancel.
+
+### 3. `View.Pasted` — observation only
+
+```csharp
+field.Pasted += (_, args) => log.AppendLine ($"Inserted: {args.Text}");
+```
+
+Raised after `OnPaste` has consumed the paste.
+
+## Customizing paste behavior in a custom view
+
+The default `View` declines pastes (its `OnPaste` returns `false`). Text-input views override two virtual methods:
+
+```csharp
+protected override string OnSanitizingPaste (string raw)
+{
+ // Return the text you want inserted. Strip controls, normalize line
+ // endings, etc. Default implementation strips C0/C1 controls except \t \n \r.
+}
+
+protected override bool OnPaste (string sanitized)
+{
+ // Insert the sanitized text. Return true if you consumed the paste.
+}
+```
+
+## Default behavior in TextField / TextView
+
+| View | `OnSanitizingPaste` | `OnPaste` |
+|-------------|------------------------------------------------------------------------------|--------------------------------------------|
+| `TextField` | First line only, strip C0/C1 controls (including tab). | Insert at cursor; respects `ReadOnly`. |
+| `TextView` | Normalize `\r` and `\r\n` to `\n`; strip C0/C1 controls except tab/newline. | Insert (multi-line aware); respects `ReadOnly`. |
+
+`TextField`'s "first line only" matches the legacy clipboard `Paste` command. `TextView`'s line-ending normalization mirrors [Windows Terminal's `FilterStringForPaste`](https://github.com/microsoft/terminal/blob/main/src/types/utils.cpp).
+
+## Terminals without bracketed paste support
+
+On terminals that do not support bracketed paste mode (older versions of `xterm` without the feature compiled in, or terminals where the user has explicitly disabled it), the markers never arrive. The paste flows through as ordinary key events and the `Paste` event never fires. No probing or fallback is needed.
+
+## Stranded pastes
+
+If the terminal sends `ESC[200~` but the matching `ESC[201~` is dropped (broken connection, terminated remote shell), the parser would otherwise hold the buffered paste content forever. Terminal.Gui flushes the partial buffer as a `Paste` event after a 5-second idle timeout measured from the most recent paste byte so an active slow paste is not cut off prematurely. There is also a hard cap of 1 MiB on the paste buffer size; payloads exceeding it are truncated, and the remaining bytes are discarded until the matching end marker arrives so tail bytes do not leak into normal input processing.
+
+## Security considerations
+
+Terminal.Gui trusts the *terminal* to sanitize the paste payload. Terminals such as xterm, Windows Terminal, Alacritty, and kitty already strip dangerous control sequences from the clipboard before bracketing — see [`xterm` `allowPasteControls`](https://invisible-island.net/xterm/xterm-paste64.html), [Windows Terminal `FilterStringForPaste`](https://github.com/microsoft/terminal/blob/main/src/types/utils.cpp), and [Alacritty's pre-bracket filter](https://github.com/alacritty/alacritty/blob/master/alacritty/src/event.rs).
+
+For defense in depth, the default `View.OnSanitizingPaste` strips C0/C1 control characters before inserting. `TextField` and `TextView` apply additional view-specific sanitization. Applications that consume the raw `Application.Paste` event are responsible for their own sanitization.
+
+## Disabling bracketed paste
+
+To opt out, intercept the driver setup and skip the enable sequence. The relevant constant is . There is no built-in application-level toggle; if you have a need for one, file a feature request.
+
+## Related
+
+- — application-level event (raw payload)
+- — cancellable view event, mutable `Text`
+- — view event after insertion
+- — view virtual hook for filtering
+- — view virtual hook for insertion
+- — the canonical paste command
+- — `IApplication.Paste` arguments
+- — `View.Pasting` arguments
+- — `View.Pasted` arguments
+- / — driver-level constants
diff --git a/docfx/docs/toc.yml b/docfx/docs/toc.yml
index 3dfd660a78..84df95b59c 100644
--- a/docfx/docs/toc.yml
+++ b/docfx/docs/toc.yml
@@ -20,6 +20,8 @@
href: arrangement.md
- name: Borders
href: borders.md
+- name: Bracketed Paste
+ href: bracketed-paste.md
- name: Cancellable Work Pattern
href: cancellable-work-pattern.md
- name: Command
| | | |