From 5a93983055d920a9d9b1868c3b0457a13a9ae5c6 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Mon, 2 Mar 2026 01:18:29 +0800 Subject: [PATCH 1/8] handle bracketed paste in isearch mode --- Lib/_pyrepl/historical_reader.py | 19 ++++++++++++++- Lib/test/test_pyrepl/test_pyrepl.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index c4b95fa2e81ee60..7a17c31f126abea 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -31,7 +31,7 @@ isearch_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( - [("\\%03o" % c, "isearch-end") for c in range(256) if chr(c) != "\\"] + [("\\%03o" % c, "isearch-end") for c in range(256) if chr(c) not in ("\\", "\x1b")] + [(c, "isearch-add-character") for c in map(chr, range(32, 127)) if c != "\\"] + [ ("\\%03o" % c, "isearch-add-character") @@ -45,6 +45,7 @@ (r"\C-c", "isearch-cancel"), (r"\C-g", "isearch-cancel"), (r"\", "isearch-backspace"), + (r"\x1b[200~", "isearch-bracketed-paste"), ] ) @@ -209,6 +210,21 @@ def do(self) -> None: r.pop_input_trans() r.dirty = True +class isearch_bracketed_paste(commands.Command): + def do(self) -> None: + r = self.reader + b = r.buffer + done = "\x1b[201~" + data = "" + while done not in data: + ev = r.console.getpending() + data += ev.data + paste_content = data.replace(done, "") + r.isearch_term += paste_content + r.dirty = True + if "".join(b[r.pos:r.pos+len(r.isearch_term)]) != r.isearch_term: + r.isearch_next() + @dataclass class HistoricalReader(Reader): @@ -245,6 +261,7 @@ def __post_init__(self) -> None: isearch_backspace, isearch_forwards, isearch_backwards, + isearch_bracketed_paste, operate_and_get_next, history_search_backward, history_search_forward, diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 35a1733787e7a2f..6839147b944a3f1 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1509,6 +1509,44 @@ def test_bracketed_paste_single_line(self): output = multiline_input(reader) self.assertEqual(output, input_code) + def test_bracketed_paste_in_isearch(self): + paste_start = "\x1b[200~" + paste_end = "\x1b[201~" + + events = itertools.chain( + # Add some history + code_to_events("print('hello')\n"), + # Search for 'hello' + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + ], + code_to_events(paste_start), + code_to_events("hello"), + code_to_events(paste_end), + [ + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + [ + Event(evt="key", data="\x12", raw=bytearray(b"\x12")), + ], + # Search for 'world', which should not be found + code_to_events(paste_start), + code_to_events("world"), + code_to_events(paste_end), + [ + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + reader = self.prepare_reader(events) + multiline_input(reader) + output = multiline_input(reader) + self.assertEqual(output, "print('hello')") + output = multiline_input(reader) + self.assertEqual(output, "") + @skipUnless(pty, "requires pty") class TestDumbTerminal(ReplTestCase): From b2ffa420c68ca3ee9b6dc147ab079f0c9d2e0402 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Mon, 2 Mar 2026 02:51:30 +0800 Subject: [PATCH 2/8] blurb --- .../next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst diff --git a/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst b/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst new file mode 100644 index 000000000000000..4f9eda901db34f2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-03-02-02-51-27.gh-issue-145375.j9r8TS.rst @@ -0,0 +1 @@ +Handle bracketed paste in :mod:`!_pyrepl` isearch mode. From 913410635038dfed43d141e70cb2d1f138f30dbd Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 5 Jul 2026 14:21:18 +0800 Subject: [PATCH 3/8] adapt code for #146584 (structured rendered screens) --- Lib/_pyrepl/historical_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index e3242d910ca6b7e..71a75fd63d9bf39 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -222,7 +222,7 @@ def do(self) -> None: data += ev.data paste_content = data.replace(done, "") r.isearch_term += paste_content - r.dirty = True + r.invalidate_prompt() if "".join(b[r.pos:r.pos+len(r.isearch_term)]) != r.isearch_term: r.isearch_next() From 235c6e48587b51c6ad5e6d489fdbe8357760e680 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Fri, 17 Jul 2026 00:55:49 +0800 Subject: [PATCH 4/8] Nit: two blank lines for top-level classes --- Lib/_pyrepl/historical_reader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 71a75fd63d9bf39..2e46989b9ae24a6 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -211,6 +211,7 @@ def do(self) -> None: r.pop_input_trans() r.invalidate_prompt() + class isearch_bracketed_paste(commands.Command): def do(self) -> None: r = self.reader From 3ad66c13fa85470982d4e4d0d304a6f123d46ddc Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 15:32:33 +0800 Subject: [PATCH 5/8] gh-145398: Translate bracketed-paste start marker into an event The event queue now turns the terminal's \x1b[200~ bracketed-paste start sequence into a synthetic "bracketed paste" named event, so readers can bind \ instead of matching the raw escape sequence directly. --- Lib/_pyrepl/base_eventqueue.py | 5 +++++ Lib/_pyrepl/keymap.py | 1 + 2 files changed, 6 insertions(+) diff --git a/Lib/_pyrepl/base_eventqueue.py b/Lib/_pyrepl/base_eventqueue.py index f520ffd7d8ad11d..cb823ae50693049 100644 --- a/Lib/_pyrepl/base_eventqueue.py +++ b/Lib/_pyrepl/base_eventqueue.py @@ -30,8 +30,13 @@ from .console import Event from .trace import trace +PASTE_KEYCODES = { + b'\033[200~': 'bracketed paste', +} + class BaseEventQueue: def __init__(self, encoding: str, keymap_dict: dict[bytes, str]) -> None: + keymap_dict.update(PASTE_KEYCODES) self.compiled_keymap = keymap.compile_keymap(keymap_dict) self.keymap = self.compiled_keymap trace("keymap {k!r}", k=self.keymap) diff --git a/Lib/_pyrepl/keymap.py b/Lib/_pyrepl/keymap.py index d11df4b5164696e..aebf8b0fe579856 100644 --- a/Lib/_pyrepl/keymap.py +++ b/Lib/_pyrepl/keymap.py @@ -98,6 +98,7 @@ "space": " ", "tab": "\t", "up": "up", + "bracketed paste": "bracketed paste", } From 66c15feb3485a7dd09c492625e69970820c9bb40 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 15:32:33 +0800 Subject: [PATCH 6/8] gh-145398: Handle bracketed paste in isearch mode Bind \ to isearch-bracketed-paste and keep \x1b bound to isearch-end. Because the paste start marker is now consumed by the event queue, a lone Esc still ends the search immediately (no disambiguation delay), while a paste is dispatched to the command. --- Lib/_pyrepl/historical_reader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/historical_reader.py b/Lib/_pyrepl/historical_reader.py index 2e46989b9ae24a6..e15c556b46c3487 100644 --- a/Lib/_pyrepl/historical_reader.py +++ b/Lib/_pyrepl/historical_reader.py @@ -31,7 +31,7 @@ isearch_keymap: tuple[tuple[KeySpec, CommandName], ...] = tuple( - [("\\%03o" % c, "isearch-end") for c in range(256) if chr(c) not in ("\\", "\x1b")] + [("\\%03o" % c, "isearch-end") for c in range(256) if chr(c) != "\\"] + [(c, "isearch-add-character") for c in map(chr, range(32, 127)) if c != "\\"] + [ ("\\%03o" % c, "isearch-add-character") @@ -45,7 +45,7 @@ (r"\C-c", "isearch-cancel"), (r"\C-g", "isearch-cancel"), (r"\", "isearch-backspace"), - (r"\x1b[200~", "isearch-bracketed-paste"), + (r"\", "isearch-bracketed-paste"), ] ) From d78c4510062dc38ef5fa72e607c0b632e07ba801 Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 15:32:33 +0800 Subject: [PATCH 7/8] gh-145398: Bind bracketed paste in the default keymap Update the normal (non-isearch) keymap to use \ so it stays consistent with the event queue translation. --- Lib/_pyrepl/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 832e67b534f2969..1b4ccf308f769af 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -128,7 +128,7 @@ def make_default_commands() -> dict[CommandName, CommandClass]: (r"\M-9", "digit-arg"), (r"\M-\n", "accept"), ("\\\\", "self-insert"), - (r"\x1b[200~", "perform-bracketed-paste"), + (r"\", "perform-bracketed-paste"), (r"\x03", "ctrl-c"), ] + [(c, "self-insert") for c in map(chr, range(32, 127)) if c != "\\"] From 0d31cd31429ea569ad960819c4354765de61e36d Mon Sep 17 00:00:00 2001 From: Tan Long Date: Sun, 26 Jul 2026 15:32:34 +0800 Subject: [PATCH 8/8] gh-145398: Add tests for bracketed-paste event and isearch paste - test_eventqueue: assert \x1b[200~ is translated to "bracketed paste". - test_pyrepl: feed the "bracketed paste" event and cover isearch mode. --- Lib/test/test_pyrepl/test_eventqueue.py | 10 ++++++++++ Lib/test/test_pyrepl/test_pyrepl.py | 18 +++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Lib/test/test_pyrepl/test_eventqueue.py b/Lib/test/test_pyrepl/test_eventqueue.py index 56ab43211847a49..6d395fb52b09c59 100644 --- a/Lib/test/test_pyrepl/test_eventqueue.py +++ b/Lib/test/test_pyrepl/test_eventqueue.py @@ -95,6 +95,16 @@ def test_push_with_keymap_in_keymap_and_escape(self, mock_keymap): self.assertEqual(eq.events[1].evt, "key") self.assertEqual(eq.events[1].data, "b") + def test_push_bracketed_paste(self): + eq = self.make_eventqueue() + for byte in b"\x1b[200~": + eq.push(byte) + event = eq.get() + self.assertIsNotNone(event) + self.assertEqual(event.evt, "key") + self.assertEqual(event.data, "bracketed paste") + self.assertEqual(event.raw, b"\x1b[200~") + def test_push_special_key(self): eq = self.make_eventqueue() eq.keymap = {} diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index b2508d8cd924eee..764278c28978446 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1952,11 +1952,11 @@ def test_bracketed_paste(self): ) # fmt: on - paste_start = "\x1b[200~" + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] paste_end = "\x1b[201~" events = itertools.chain( - code_to_events(paste_start), + paste_start, code_to_events(input_code), code_to_events(paste_end), code_to_events("\n"), @@ -1968,11 +1968,11 @@ def test_bracketed_paste(self): def test_bracketed_paste_single_line(self): input_code = "oneline" - paste_start = "\x1b[200~" + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] paste_end = "\x1b[201~" events = itertools.chain( - code_to_events(paste_start), + paste_start, code_to_events(input_code), code_to_events(paste_end), code_to_events("\n"), @@ -1982,7 +1982,11 @@ def test_bracketed_paste_single_line(self): self.assertEqual(output, input_code) def test_bracketed_paste_in_isearch(self): - paste_start = "\x1b[200~" + # The bracketed-paste *start* marker is translated into a synthetic + # ``bracketed paste`` event by the real event queue; the fake console + # used here does not run that translation, so we feed the + # ``bracketed paste`` event directly. + paste_start = [Event(evt="key", data="bracketed paste", raw=bytearray(b"\x1b[200~"))] paste_end = "\x1b[201~" events = itertools.chain( @@ -1992,7 +1996,7 @@ def test_bracketed_paste_in_isearch(self): [ Event(evt="key", data="\x12", raw=bytearray(b"\x12")), ], - code_to_events(paste_start), + paste_start, code_to_events("hello"), code_to_events(paste_end), [ @@ -2003,7 +2007,7 @@ def test_bracketed_paste_in_isearch(self): Event(evt="key", data="\x12", raw=bytearray(b"\x12")), ], # Search for 'world', which should not be found - code_to_events(paste_start), + paste_start, code_to_events("world"), code_to_events(paste_end), [