From f2eaddcbab268b0aefc3b8c05d1abc1271ed16dd Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 16 Jun 2026 16:39:03 -0700 Subject: [PATCH 1/3] wip PyREPL: Add ctrl+up/down for multiline history Currently, the up/down keys move the cursor in that direction inside the current code block until it reaches the beginning/end, then it goes to the previous/next. However, when traversing history with lots of multiline blocks, this can be a cumbersome process. This PR introduces the ability to traverse history one block at a time using the CTRL+Up or CTRL+Down keys. Originated from https://discuss.python.org/t/pyrepl-add-ctrl-up-down-for-multiline-history-retrieving/107779 --- Lib/_pyrepl/commands.py | 17 +++++++++++++++++ Lib/_pyrepl/keymap.py | 2 +- Lib/_pyrepl/reader.py | 2 ++ Lib/_pyrepl/unix_eventqueue.py | 4 ++++ Lib/_pyrepl/windows_eventqueue.py | 2 ++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index e79fbfa6bb0b38..48d6dbe2e13007 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -371,6 +371,23 @@ def do(self) -> None: for i in range(r.get_arg()): r.pos = r.bow() +class up_history(MotionCommand): + def do(self) -> None: + r = self.reader + if r.historyi > 0: + r.select_item(r.historyi - 1) + else: + r.error("start of history") + return + +class down_history(MotionCommand): + def do(self) -> None: + r = self.reader + if r.historyi < len(r.history): + r.select_item(r.historyi + 1) + else: + r.error("end of history") + return class self_insert(EditCommand): def do(self) -> None: diff --git a/Lib/_pyrepl/keymap.py b/Lib/_pyrepl/keymap.py index d11df4b5164696..3dfefde8cb1fcd 100644 --- a/Lib/_pyrepl/keymap.py +++ b/Lib/_pyrepl/keymap.py @@ -183,7 +183,7 @@ def _parse_single_key_sequence(key: str, s: int) -> tuple[list[str], int]: if ctrl: if len(ret) == 1: ret = chr(ord(ret) & 0x1F) # curses.ascii.ctrl() - elif ret in {"left", "right"}: + elif ret in {"left", "right", "up", "down"}: ret = f"ctrl {ret}" else: raise KeySpecError("\\C- followed by invalid key") diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 832e67b534f296..06977884562ad2 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -135,7 +135,9 @@ def make_default_commands() -> dict[CommandName, CommandClass]: + [(c, "self-insert") for c in map(chr, range(128, 256)) if c.isalpha()] + [ (r"\", "up"), + (r"\C-\", "up_history"), (r"\", "down"), + (r"\C-\", "down_history"), (r"\", "left"), (r"\C-\", "backward-word"), (r"\", "right"), diff --git a/Lib/_pyrepl/unix_eventqueue.py b/Lib/_pyrepl/unix_eventqueue.py index 2a9cca59e7477f..b4ad84ec217126 100644 --- a/Lib/_pyrepl/unix_eventqueue.py +++ b/Lib/_pyrepl/unix_eventqueue.py @@ -49,9 +49,13 @@ # for xterm, gnome-terminal, xfce terminal, etc. b'\033[1;5D': 'ctrl left', b'\033[1;5C': 'ctrl right', + b'\033[1;5A': 'ctrl up', + b'\033[1;5B': 'ctrl down', # for rxvt b'\033Od': 'ctrl left', b'\033Oc': 'ctrl right', + b'\033Oa': 'ctrl up', + b'\033Ob': 'ctrl down', } def get_terminal_keycodes(ti: TermInfo) -> dict[bytes, str]: diff --git a/Lib/_pyrepl/windows_eventqueue.py b/Lib/_pyrepl/windows_eventqueue.py index d99722f9a16a93..f56537092e2bce 100644 --- a/Lib/_pyrepl/windows_eventqueue.py +++ b/Lib/_pyrepl/windows_eventqueue.py @@ -13,6 +13,8 @@ b'\x1b[D': 'left', b'\x1b[1;5D': 'ctrl left', b'\x1b[1;5C': 'ctrl right', + b'\x1b[1;5A': 'ctrl up', + b'\x1b[1;5B': 'ctrl down', b'\x1b[H': 'home', b'\x1b[F': 'end', From 43fe1bba7af4604bed2bed219cc42e40843a056d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 16 Jun 2026 16:58:58 -0700 Subject: [PATCH 2/3] Add tests --- Lib/test/test_pyrepl/test_keymap.py | 6 +++ Lib/test/test_pyrepl/test_pyrepl.py | 73 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/Lib/test/test_pyrepl/test_keymap.py b/Lib/test/test_pyrepl/test_keymap.py index 2c97066b2c7043..91fcf0a1b33105 100644 --- a/Lib/test/test_pyrepl/test_keymap.py +++ b/Lib/test/test_pyrepl/test_keymap.py @@ -48,6 +48,12 @@ def test_combinations(self): self.assertEqual(parse_keys("\\C-a\\n\\"), ["\x01", "\n", "up"]) self.assertEqual(parse_keys("\\M-a\\t\\"), ["\033", "a", "\t", "down"]) + def test_control_arrow_keys(self): + self.assertEqual(parse_keys("\\C-\\"), ["ctrl left"]) + self.assertEqual(parse_keys("\\C-\\"), ["ctrl right"]) + self.assertEqual(parse_keys("\\C-\\"), ["ctrl up"]) + self.assertEqual(parse_keys("\\C-\\"), ["ctrl down"]) + def test_keyspec_errors(self): cases = [ ("\\Ca", "\\C must be followed by `-'"), diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 4240a3c3174959..92202696143178 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -850,6 +850,79 @@ def test_history_navigation_with_down_arrow(self): self.assertEqual(output, "1+1") self.assert_screen_equal(reader, "1+1", clean=True) + def test_history_navigation_with_ctrl_up(self): + # Submit two multiline blocks, then use ctrl+up to jump directly + # to the previous history entry (unlike up which moves line-by-line) + code = "def foo():\nx = 1\n\ndef bar():\ny = 2\n\n" + events = itertools.chain( + code_to_events(code), + [ + # Single ctrl+up should recall the entire previous entry + Event(evt="key", data="ctrl up", raw=bytearray(b"\x1b[1;5A")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + reader = self.prepare_reader(events) + + output = multiline_input(reader) + self.assertEqual(output, "def foo():\n x = 1\n ") + output = multiline_input(reader) + self.assertEqual(output, "def bar():\n y = 2\n ") + # One ctrl+up jumps straight to previous history item + output = multiline_input(reader) + self.assertEqual(output, "def foo():\n x = 1\n ") + + def test_history_navigation_with_ctrl_down(self): + # Submit two multiline entries, ctrl+up twice then ctrl+down once + # With multiline entries, regular down would only move one line, + # but ctrl+down jumps to the next history entry entirely. + code = "def foo():\nx = 1\n\ndef bar():\ny = 2\n\n" + events = itertools.chain( + code_to_events(code), + [ + Event(evt="key", data="ctrl up", raw=bytearray(b"\x1b[1;5A")), + Event(evt="key", data="ctrl up", raw=bytearray(b"\x1b[1;5A")), + Event(evt="key", data="ctrl down", raw=bytearray(b"\x1b[1;5B")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + reader = self.prepare_reader(events) + + output = multiline_input(reader) + self.assertEqual(output, "def foo():\n x = 1\n ") + output = multiline_input(reader) + self.assertEqual(output, "def bar():\n y = 2\n ") + # ctrl+up twice (to foo), ctrl+down once (back to bar) + output = multiline_input(reader) + self.assertEqual(output, "def bar():\n y = 2\n ") + + def test_ctrl_up_at_start_of_history(self): + # Submit a multiline block, then try ctrl+up twice + # (second one should error since we're already at the oldest entry) + code = "def foo():\nx = 1\n\n" + events = itertools.chain( + code_to_events(code), + [ + # Already at oldest item; second ctrl+up should error but not crash + Event(evt="key", data="ctrl up", raw=bytearray(b"\x1b[1;5A")), + Event(evt="key", data="ctrl up", raw=bytearray(b"\x1b[1;5A")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + Event(evt="key", data="\n", raw=bytearray(b"\n")), + ], + ) + + reader = self.prepare_reader(events) + + output = multiline_input(reader) + self.assertEqual(output, "def foo():\n x = 1\n ") + # Second ctrl+up at start of history stays on first item + output = multiline_input(reader) + self.assertEqual(output, "def foo():\n x = 1\n ") + def test_history_search(self): events = itertools.chain( code_to_events("1+1\n2+2\n3+3\n"), From bb957e239f6bb915334945174b6acfafaf9c1873 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 16 Jun 2026 17:01:41 -0700 Subject: [PATCH 3/3] Refactor --- Lib/_pyrepl/commands.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Lib/_pyrepl/commands.py b/Lib/_pyrepl/commands.py index 48d6dbe2e13007..4fe10ce188c1cd 100644 --- a/Lib/_pyrepl/commands.py +++ b/Lib/_pyrepl/commands.py @@ -377,8 +377,7 @@ def do(self) -> None: if r.historyi > 0: r.select_item(r.historyi - 1) else: - r.error("start of history") - return + r.error("start of buffer") class down_history(MotionCommand): def do(self) -> None: @@ -386,8 +385,7 @@ def do(self) -> None: if r.historyi < len(r.history): r.select_item(r.historyi + 1) else: - r.error("end of history") - return + r.error("end of buffer") class self_insert(EditCommand): def do(self) -> None: