Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 98 additions & 33 deletions hermes_cli/curses_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
47 changes: 34 additions & 13 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
25 changes: 15 additions & 10 deletions hermes_cli/plugins_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions tests/hermes_cli/test_curses_arrow_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading