From 84f455665aa8b79280bac11708c29571a39d1002 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 31 May 2026 14:24:10 +0530 Subject: [PATCH] fix(cli): decode arrow/nav escape sequences in remaining curses menus Follow-up to the setup-wizard arrow-key fix. Two more interactive curses menus had their own raw stdscr.getch() loops that only matched curses.KEY_* and treated a leading byte 27 (ESC) as cancel, so on terminals that deliver cursor keys as raw CSI/SS3 byte sequences (Ghostty among them) pressing up/down misfired: - hermes plugins manager (incl. the 'Provider Plugins' group/category rows): arrows bailed out via the {27, q} 'save & exit' branch. - session browser (hermes browse): arrows cleared the filter / exited, and the trailing sequence bytes ([A, [B, ...) were injected into the type-to-filter search box as literal text. Generalize the shared helper instead of duplicating decode logic: - Add read_menu_key_ex() returning (action, raw_key) plus NAV_PAGE_UP/ PAGE_DOWN/HOME/END/BACKSPACE. Decode the full CSI/SS3 vocabulary: arrows, PgUp/PgDn (5~/6~), Home/End (H/F and 1~/4~/7~/8~), modified arrows with parameters (e.g. [1;2B), and consume unhandled sequences (Delete 3~) up to their terminator so no bytes leak. - An ESC immediately followed by a non-introducer byte (Alt-combo, fast typing, paste) now registers as a lone-ESC cancel and pushes the trailing byte back via curses.ungetch instead of swallowing it. - letters_are_nav=False mode for the session browser: j/k/space/q stay typeable filter characters while real arrows (translated + escape) and Enter/Backspace/Esc still navigate. Route plugins_cmd.py and main.py's session browser through the helper. read_menu_key() is now a thin wrapper over read_menu_key_ex(). Tests: new test_curses_menu_nav.py (paging, Home/End in letter+numeric forms, modified arrows, letters_are_nav both modes, ESC-then-key ungetch). Updated the Home/End assertion in test_curses_arrow_keys.py (now decoded, was ignored) and taught the session-browse harness to honor curses.ungetch. Full related suite: 782 passed, 0 regressions. --- hermes_cli/curses_ui.py | 131 +++++++++++++----- hermes_cli/main.py | 47 +++++-- hermes_cli/plugins_cmd.py | 25 ++-- tests/hermes_cli/test_curses_arrow_keys.py | 11 +- tests/hermes_cli/test_curses_menu_nav.py | 152 +++++++++++++++++++++ tests/hermes_cli/test_session_browse.py | 21 ++- 6 files changed, 324 insertions(+), 63 deletions(-) create mode 100644 tests/hermes_cli/test_curses_menu_nav.py diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index e2c2af626479e..e89b52009d99a 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -39,39 +39,68 @@ def flush_stdin() -> None: NAV_SELECT = "select" NAV_TOGGLE = "toggle" NAV_CANCEL = "cancel" +NAV_PAGE_UP = "page_up" +NAV_PAGE_DOWN = "page_down" +NAV_HOME = "home" +NAV_END = "end" +NAV_BACKSPACE = "backspace" NAV_NONE = "none" -def read_menu_key(stdscr) -> str: - """Read one keypress and normalize it to a menu action. +def read_menu_key_ex(stdscr, *, letters_are_nav: bool = True): + """Read one keypress; return ``(action, raw_key)``. + + ``action`` is one of the ``NAV_*`` constants. ``raw_key`` is the integer + returned by the initial ``getch()`` — callers that also accept literal + typed characters (e.g. a type-to-filter menu) use it to recover the + printable byte when ``action`` is :data:`NAV_NONE`. + + Set ``letters_are_nav=False`` for menus that accept typed text (e.g. the + session browser's filter): then the vim aliases ``j``/``k``, ``SPACE`` and + ``q`` are reported as :data:`NAV_NONE` (with their raw byte) instead of + navigation/toggle/cancel, so they can be inserted into the filter. The + translated ``curses.KEY_*`` arrows and decoded escape sequences still + navigate regardless. - Decodes raw arrow-key escape sequences in addition to the translated + Decodes raw arrow/navigation escape sequences in addition to the translated ``curses.KEY_*`` values. Even with ``keypad(True)`` (which ``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor - keys as raw CSI/SS3 byte sequences — ``getch()`` then returns ``27`` (ESC) - followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is - what made the setup wizard's provider/model pickers bail to the numbered - fallback the moment a user pressed up/down. - - Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte - within a short window) is the only thing that maps to ``NAV_CANCEL`` via - the escape path; ``q`` also cancels. Unknown sequences map to - ``NAV_NONE`` so the caller simply ignores them rather than misfiring. + and navigation keys as raw CSI/SS3 byte sequences — ``getch()`` then returns + ``27`` (ESC) followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a + cancel is what made the setup wizard's pickers bail to the numbered fallback + (and made the session browser inject ``[A`` into its filter) the moment a + user pressed an arrow key. + + Any escape sequence we don't map to a navigation action is consumed up to + its terminator and reported as :data:`NAV_NONE` with ``raw_key == 27`` so + its tail bytes can never leak into a filter buffer or the next ``input()``. + A genuine lone ESC (no continuation byte within a short window) maps to + :data:`NAV_CANCEL`. """ import curses key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): - return NAV_UP - if key in (curses.KEY_DOWN, ord("j")): - return NAV_DOWN + if key == curses.KEY_UP or (letters_are_nav and key == ord("k")): + return NAV_UP, key + if key == curses.KEY_DOWN or (letters_are_nav and key == ord("j")): + return NAV_DOWN, key if key in (curses.KEY_ENTER, 10, 13): - return NAV_SELECT - if key == ord(" "): - return NAV_TOGGLE - if key == ord("q"): - return NAV_CANCEL + return NAV_SELECT, key + if letters_are_nav and key == ord(" "): + return NAV_TOGGLE, key + if letters_are_nav and key == ord("q"): + return NAV_CANCEL, key + if key == curses.KEY_NPAGE: + return NAV_PAGE_DOWN, key + if key == curses.KEY_PPAGE: + return NAV_PAGE_UP, key + if key == curses.KEY_HOME: + return NAV_HOME, key + if key == curses.KEY_END: + return NAV_END, key + if key in (curses.KEY_BACKSPACE, 127, 8): + return NAV_BACKSPACE, key if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence. # Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the @@ -84,24 +113,60 @@ def read_menu_key(stdscr) -> str: stdscr.timeout(-1) # restore blocking mode if nxt == -1: - return NAV_CANCEL # genuine lone ESC + return NAV_CANCEL, key # genuine lone ESC if nxt in (ord("["), ord("O")): # CSI / SS3 introducer + # Read the rest of the sequence: optional parameter/intermediate + # bytes (0x20–0x3F) followed by a single final byte (0x40–0x7E). + params = "" final = stdscr.getch() + while 0x20 <= final <= 0x3F: + if 0x30 <= final <= 0x39 or final == ord(";"): + params += chr(final) + final = stdscr.getch() + + # Single-letter finals: arrows + Home/End (xterm/SS3 forms). if final in (ord("A"), ord("k")): - return NAV_UP + return NAV_UP, key if final in (ord("B"), ord("j")): - return NAV_DOWN - # Consume the tail of any other CSI sequence (e.g. ``[3~`` Delete, - # ``[H`` Home) up to its terminator so stray bytes don't leak into - # the next input() and corrupt it. - while 0x20 <= final <= 0x3F: # CSI parameter/intermediate bytes - final = stdscr.getch() - return NAV_NONE - # ESC followed by some other byte we don't handle — swallow it. - return NAV_NONE + return NAV_DOWN, key + if final == ord("H"): + return NAV_HOME, key + if final == ord("F"): + return NAV_END, key + # Numeric "~"-terminated finals: ESC [ 5 ~ = PgUp, 6 ~ = PgDn, + # 1~/7~ = Home, 4~/8~ = End, 3~ = Delete (ignored). + if final == ord("~"): + mapping = { + "1": NAV_HOME, "7": NAV_HOME, + "4": NAV_END, "8": NAV_END, + "5": NAV_PAGE_UP, "6": NAV_PAGE_DOWN, + } + return mapping.get(params, NAV_NONE), key + return NAV_NONE, key + # ESC followed by a byte that is NOT a CSI/SS3 introducer: this is a + # genuine ESC press immediately followed by another key (Alt-combo, + # fast typing, or a paste). Treat the ESC as a lone ESC (cancel) and + # push the stray byte back so the next read processes it normally — + # never silently swallow it. + try: + if nxt != -1: + curses.ungetch(nxt) + except Exception: + pass + return NAV_CANCEL, key + + return NAV_NONE, key - return NAV_NONE + +def read_menu_key(stdscr) -> str: + """Read one keypress and normalize it to a menu action. + + Thin wrapper over :func:`read_menu_key_ex` for callers that only need the + normalized ``NAV_*`` action and not the raw key code. See + ``read_menu_key_ex`` for the full decoding behavior. + """ + return read_menu_key_ex(stdscr)[0] def curses_checklist( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1cb4bd3d6b897..fdd258d287c5a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -728,6 +728,11 @@ def _match(s, query): ) def _curses_browse(stdscr): + from hermes_cli.curses_ui import ( + read_menu_key_ex, NAV_UP, NAV_DOWN, NAV_PAGE_UP, + NAV_PAGE_DOWN, NAV_HOME, NAV_END, NAV_SELECT, NAV_CANCEL, + NAV_BACKSPACE, NAV_NONE, + ) curses.curs_set(0) if curses.has_colors(): curses.start_color() @@ -845,29 +850,41 @@ def _curses_browse(stdscr): pass stdscr.refresh() - key = stdscr.getch() + action, raw_key = read_menu_key_ex(stdscr, letters_are_nav=False) - if key in {curses.KEY_UP,}: + if action == NAV_UP: if filtered: cursor = (cursor - 1) % len(filtered) - elif key in {curses.KEY_DOWN,}: + elif action == NAV_DOWN: if filtered: cursor = (cursor + 1) % len(filtered) - elif key in {curses.KEY_ENTER, 10, 13}: + elif action == NAV_PAGE_UP: + if filtered: + cursor = max(0, cursor - max(1, visible_rows)) + elif action == NAV_PAGE_DOWN: + if filtered: + cursor = min(len(filtered) - 1, cursor + max(1, visible_rows)) + elif action == NAV_HOME: + cursor = 0 + elif action == NAV_END: + if filtered: + cursor = len(filtered) - 1 + elif action == NAV_SELECT: if filtered: result_holder[0] = filtered[cursor]["id"] return - elif key == 27: # Esc + elif action == NAV_CANCEL: + # Esc only (q is treated as a filter character here). if search_text: - # First Esc clears the search + # First Esc clears the search. search_text = "" filtered = list(sessions) cursor = 0 scroll_offset = 0 else: - # Second Esc exits + # Second Esc (or Esc with no filter) exits. return - elif key in {curses.KEY_BACKSPACE, 127, 8}: + elif action == NAV_BACKSPACE: if search_text: search_text = search_text[:-1] if search_text: @@ -876,11 +893,15 @@ def _curses_browse(stdscr): filtered = list(sessions) cursor = 0 scroll_offset = 0 - elif key == ord("q") and not search_text: - return - elif 32 <= key <= 126: - # Printable character → add to search filter - search_text += chr(key) + elif action == NAV_NONE and 32 <= raw_key <= 126: + # Printable character → add to search filter. With + # letters_are_nav=False, q/j/k/space arrive here too. + # Escape-sequence bytes never reach here: read_menu_key_ex + # consumes them whole and returns NAV_NONE with raw_key == 27. + if not search_text and chr(raw_key) == "q": + # 'q' with no active filter quits (legacy shortcut). + return + search_text += chr(raw_key) filtered = [s for s in sessions if _match(s, search_text)] cursor = 0 scroll_offset = 0 diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index f8d2184e673c7..f9f0ed320fbbd 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1136,6 +1136,11 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, result_holder = {"plugins_changed": False, "providers_changed": False} + from hermes_cli.curses_ui import ( + read_menu_key, NAV_UP, NAV_DOWN, NAV_PAGE_UP, NAV_PAGE_DOWN, + NAV_HOME, NAV_END, NAV_TOGGLE, NAV_SELECT, NAV_CANCEL, + ) + def _draw(stdscr): curses.curs_set(0) if curses.has_colors(): @@ -1252,25 +1257,25 @@ def _draw(stdscr): y += 1 stdscr.refresh() - key = stdscr.getch() + action = read_menu_key(stdscr) - if key in {curses.KEY_UP, ord("k")}: + if action == NAV_UP: if total_items > 0: cursor = (cursor - 1) % total_items - elif key in {curses.KEY_DOWN, ord("j")}: + elif action == NAV_DOWN: if total_items > 0: cursor = (cursor + 1) % total_items - elif key in {curses.KEY_NPAGE, ord("f")}: + elif action == NAV_PAGE_DOWN: if total_items > 0: cursor = min(total_items - 1, cursor + max(1, max_y - 5)) - elif key in {curses.KEY_PPAGE, ord("b")}: + elif action == NAV_PAGE_UP: if total_items > 0: cursor = max(0, cursor - max(1, max_y - 5)) - elif key == curses.KEY_HOME: + elif action == NAV_HOME: cursor = 0 - elif key == curses.KEY_END: + elif action == NAV_END: cursor = max(0, total_items - 1) - elif key == ord(" "): + elif action == NAV_TOGGLE: if cursor < n_plugins: # Toggle general plugin chosen.symmetric_difference_update({cursor}) @@ -1303,7 +1308,7 @@ def _draw(stdscr): curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) curses.curs_set(0) - elif key in {curses.KEY_ENTER, 10, 13}: + elif action == NAV_SELECT: if cursor < n_plugins: # ENTER on a plugin checkbox — confirm and exit result_holder["plugins_changed"] = True @@ -1335,7 +1340,7 @@ def _draw(stdscr): curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) curses.curs_set(0) - elif key in {27, ord("q")}: + elif action == NAV_CANCEL: # Save plugin changes on exit result_holder["plugins_changed"] = True return diff --git a/tests/hermes_cli/test_curses_arrow_keys.py b/tests/hermes_cli/test_curses_arrow_keys.py index c1bafbd8c3d45..d503aa9608e5d 100644 --- a/tests/hermes_cli/test_curses_arrow_keys.py +++ b/tests/hermes_cli/test_curses_arrow_keys.py @@ -87,10 +87,13 @@ def test_unhandled_csi_sequence_is_consumed_and_ignored(): assert fake.keys == [ord("X")] -def test_home_end_csi_sequences_ignored(): - # ESC [ H (Home) and ESC [ F (End) -> NAV_NONE, fully consumed. - assert read_menu_key(FakeStdscr([27, ord("["), ord("H")])) == NAV_NONE - assert read_menu_key(FakeStdscr([27, ord("["), ord("F")])) == NAV_NONE +def test_home_end_csi_sequences_decode(): + # ESC [ H (Home) and ESC [ F (End) now decode to navigation actions + # (the menus that use read_menu_key honor Home/End), rather than being + # silently ignored. Either way their bytes are fully consumed. + from hermes_cli.curses_ui import NAV_HOME, NAV_END + assert read_menu_key(FakeStdscr([27, ord("["), ord("H")])) == NAV_HOME + assert read_menu_key(FakeStdscr([27, ord("["), ord("F")])) == NAV_END def test_escape_uses_short_timeout_then_restores_blocking(): diff --git a/tests/hermes_cli/test_curses_menu_nav.py b/tests/hermes_cli/test_curses_menu_nav.py new file mode 100644 index 0000000000000..288aaba480bd2 --- /dev/null +++ b/tests/hermes_cli/test_curses_menu_nav.py @@ -0,0 +1,152 @@ +"""Regression tests for read_menu_key_ex — paging, Home/End, and the +type-to-filter (letters_are_nav=False) mode used by the session browser. + +These extend test_curses_arrow_keys.py (which covers the base arrow decode) +and guard the second round of Ghostty/raw-escape fixes: the `hermes plugins` +group menu and the `hermes browse` session picker. +""" +import curses + +from hermes_cli.curses_ui import ( + NAV_BACKSPACE, + NAV_CANCEL, + NAV_DOWN, + NAV_END, + NAV_HOME, + NAV_NONE, + NAV_PAGE_DOWN, + NAV_PAGE_UP, + NAV_SELECT, + NAV_TOGGLE, + NAV_UP, + read_menu_key_ex, +) + + +class FakeStdscr: + def __init__(self, keys): + self.keys = list(keys) + self.timeouts = [] + + def getch(self): + return self.keys.pop(0) if self.keys else -1 + + def timeout(self, ms): + self.timeouts.append(ms) + + +# ── tuple contract ────────────────────────────────────────────────────── +def test_ex_returns_action_and_raw_key(): + action, raw = read_menu_key_ex(FakeStdscr([ord("x")])) + assert action == NAV_NONE + assert raw == ord("x") + + +# ── paging via translated keys ────────────────────────────────────────── +def test_translated_paging_and_homeend(): + assert read_menu_key_ex(FakeStdscr([curses.KEY_NPAGE]))[0] == NAV_PAGE_DOWN + assert read_menu_key_ex(FakeStdscr([curses.KEY_PPAGE]))[0] == NAV_PAGE_UP + assert read_menu_key_ex(FakeStdscr([curses.KEY_HOME]))[0] == NAV_HOME + assert read_menu_key_ex(FakeStdscr([curses.KEY_END]))[0] == NAV_END + + +# ── paging / home / end via raw escape sequences ──────────────────────── +def test_raw_csi_pageup_pagedown(): + # ESC [ 5 ~ = PgUp, ESC [ 6 ~ = PgDn + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("5"), ord("~")]))[0] == NAV_PAGE_UP + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("6"), ord("~")]))[0] == NAV_PAGE_DOWN + + +def test_raw_csi_home_end_letter_form(): + # ESC [ H = Home, ESC [ F = End + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("H")]))[0] == NAV_HOME + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("F")]))[0] == NAV_END + + +def test_raw_csi_home_end_numeric_form(): + # ESC [ 1 ~ / 7 ~ = Home, ESC [ 4 ~ / 8 ~ = End + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("1"), ord("~")]))[0] == NAV_HOME + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("7"), ord("~")]))[0] == NAV_HOME + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("4"), ord("~")]))[0] == NAV_END + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("8"), ord("~")]))[0] == NAV_END + + +def test_delete_csi_3_tilde_ignored(): + # ESC [ 3 ~ (Delete) -> NAV_NONE, fully consumed. + fake = FakeStdscr([27, ord("["), ord("3"), ord("~"), ord("Z")]) + assert read_menu_key_ex(fake)[0] == NAV_NONE + assert fake.keys == [ord("Z")] # trailing real key untouched + + +def test_modified_arrow_with_params_still_navigates(): + # ESC [ 1 ; 2 B (Shift+Down on some terminals) -> still DOWN. + seq = [27, ord("["), ord("1"), ord(";"), ord("2"), ord("B")] + assert read_menu_key_ex(FakeStdscr(seq))[0] == NAV_DOWN + + +# ── backspace ─────────────────────────────────────────────────────────── +def test_backspace_variants(): + for k in (curses.KEY_BACKSPACE, 127, 8): + assert read_menu_key_ex(FakeStdscr([k]))[0] == NAV_BACKSPACE + + +# ── letters_are_nav default (True): vim keys + q + space are nav ───────── +def test_letters_are_nav_default_true(): + assert read_menu_key_ex(FakeStdscr([ord("j")]))[0] == NAV_DOWN + assert read_menu_key_ex(FakeStdscr([ord("k")]))[0] == NAV_UP + assert read_menu_key_ex(FakeStdscr([ord(" ")]))[0] == NAV_TOGGLE + assert read_menu_key_ex(FakeStdscr([ord("q")]))[0] == NAV_CANCEL + + +# ── letters_are_nav=False (session-browser filter mode) ───────────────── +def test_letters_are_nav_false_treats_letters_as_text(): + # j/k/q/space become typeable filter characters (NAV_NONE + raw byte). + for ch in ("j", "k", "q", " "): + action, raw = read_menu_key_ex(FakeStdscr([ord(ch)]), letters_are_nav=False) + assert action == NAV_NONE, ch + assert raw == ord(ch), ch + + +def test_letters_are_nav_false_arrows_still_navigate(): + # Real arrow keys (translated + raw escape) MUST still navigate even in + # filter mode — otherwise the picker is unusable on Ghostty. + assert read_menu_key_ex(FakeStdscr([curses.KEY_DOWN]), letters_are_nav=False)[0] == NAV_DOWN + assert read_menu_key_ex(FakeStdscr([27, ord("["), ord("B")]), letters_are_nav=False)[0] == NAV_DOWN + assert read_menu_key_ex(FakeStdscr([27, ord("O"), ord("A")]), letters_are_nav=False)[0] == NAV_UP + + +def test_letters_are_nav_false_enter_and_backspace_still_work(): + assert read_menu_key_ex(FakeStdscr([10]), letters_are_nav=False)[0] == NAV_SELECT + assert read_menu_key_ex(FakeStdscr([127]), letters_are_nav=False)[0] == NAV_BACKSPACE + + +def test_letters_are_nav_false_escape_seq_never_leaks_as_text(): + # The whole point: an arrow's escape bytes must NOT surface as printable + # filter characters. raw_key is 27 (the ESC), never '[' or 'B'. + action, raw = read_menu_key_ex(FakeStdscr([27, ord("["), ord("B")]), letters_are_nav=False) + assert action == NAV_DOWN + # And an unhandled sequence returns raw_key == 27 so the 32..126 filter + # guard rejects it. + action2, raw2 = read_menu_key_ex(FakeStdscr([27, ord("["), ord("3"), ord("~")]), letters_are_nav=False) + assert action2 == NAV_NONE + assert raw2 == 27 + assert not (32 <= raw2 <= 126) + + +def test_esc_immediately_followed_by_other_key_is_lone_esc(): + # ESC followed immediately by a non-introducer byte (Alt-combo / fast + # typing / paste): the ESC must register as a cancel and the trailing byte + # must be pushed back via curses.ungetch, never silently swallowed. + import curses as _curses + + pushed = [] + orig = _curses.ungetch + _curses.ungetch = lambda ch: pushed.append(ch) + try: + action, raw = read_menu_key_ex(FakeStdscr([27, ord("x")])) + finally: + _curses.ungetch = orig + assert action == NAV_CANCEL + assert raw == 27 + assert pushed == [ord("x")] # the 'x' was requeued, not lost + diff --git a/tests/hermes_cli/test_session_browse.py b/tests/hermes_cli/test_session_browse.py index 833729973ae55..6281b08ee7e06 100644 --- a/tests/hermes_cli/test_session_browse.py +++ b/tests/hermes_cli/test_session_browse.py @@ -248,10 +248,24 @@ class TestCursesBrowse: def _run_with_keys(self, sessions, key_sequence): """Simulate running the curses picker with a given key sequence.""" - # Build a mock stdscr that returns keys from the sequence + from collections import deque + + # Back getch with a real queue so curses.ungetch (used by the menu key + # decoder to push back a byte that followed a lone ESC) can re-enqueue + # it — faithfully modeling terminal input rather than dropping it. + pending = deque(key_sequence) + + def _getch(): + if not pending: + raise StopIteration + return pending.popleft() + + def _ungetch(ch): + pending.appendleft(ch) + mock_stdscr = MagicMock() mock_stdscr.getmaxyx.return_value = (30, 120) - mock_stdscr.getch.side_effect = key_sequence + mock_stdscr.getch.side_effect = _getch # Capture what curses.wrapper receives and call it with our mock with patch("curses.wrapper") as mock_wrapper: @@ -265,7 +279,8 @@ def run_inner(func): mock_wrapper.side_effect = run_inner with patch("curses.curs_set"): with patch("curses.has_colors", return_value=False): - return _session_browse_picker(sessions) + with patch("curses.ungetch", side_effect=_ungetch): + return _session_browse_picker(sessions) def test_enter_selects_first_session(self): sessions = _make_sessions(3)