fix(cli): decode arrow keys in curses model menus - #8335
Conversation
612af9f to
59e466a
Compare
|
Rebased on latest Quick summary of what this fixes: on some terminals the curses model/provider pickers would eat arrow-key CSI/SS3 escape sequences and jump to odd rows (or drop input entirely). This PR decodes those sequences explicitly so up/down behave consistently, and adds a non-TTY fallback for the single-select path. @teknium1 would appreciate a look when you get a moment — this touches the same area as #7167 (flush_stdin after terminal menus), so it felt like the natural follow-up. Happy to iterate on anything. |
Two regressions surfaced when reviewing the switch from simple_term_menu
to curses_single_select for model/provider pickers:
1. _prompt_model_selection built a multi-line title containing a priced
column header ("In Out /Mtok"), but curses_single_select rendered
the whole title on row 0 and immediately overwrote the second line
with the navigation hint. Priced menus thus lost their column legend.
2. The fallback path displayed unavailable paid-tier models plus an
upgrade URL, but the curses path silently dropped both and shortened
the title to "(free models)".
curses_single_select now splits the title by newlines and renders each
line on its own row, and accepts an optional footer_lines list rendered
dimly below the items. _prompt_model_selection passes the unavailable
models and upgrade hint through footer_lines so the information is
preserved in the curses menu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up: pushed a third commit addressing two regressions caught in self-review of the curses switch:
Added two unit tests covering both behaviors in |
Two follow-up regressions surfaced in review: 1. read_curses_key queued the tail bytes of unrecognized CSI/SS3 sequences (Delete = ESC [ 3 ~, Home = ESC [ H, End = ESC [ F, etc.) back into _PENDING_KEYS and returned the leading ESC. The session- browse picker then read ESC (clearing an active search filter) and injected the leftover bytes as printable characters. Now read the sequence through to its terminator (any byte in 0x40–0x7E for CSI, one byte for SS3) and return a neutral 0 when the key is not an arrow, so the menu/filter loop ignores it. 2. curses_single_select previously reserved one row per footer line, so a provider reporting ~20 unavailable paid models shrank the selectable list to a single row on a standard 24-row terminal. Cap the footer at one-third of the rows below the hint, clip the overflow, and annotate the last shown footer line with a "(+N more)" hint so users know more information is available in the numbered fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up round 2 — self-review caught two more curses regressions:
Added two more unit tests: one verifies that a Delete-style |
Three more regressions surfaced in review:
1. curses_single_select fell back to _numbered_single_fallback when
stdin was not a TTY, which calls input() — so piped/headless
invocations would block on or consume piped bytes. Now returns None
directly, matching curses_checklist's cancel-on-headless behavior.
2. The arrow-key decoder polled with nodelay(True), which returns -1
immediately when the continuation byte has not yet arrived. On
slower SSH/tmux PTYs that delivered ESC, [, A across separate
reads the decoder misread the whole sequence as a bare Escape and
cancelled the picker. Switched to stdscr.timeout(50) so we wait up
to 50 ms for each continuation byte before giving up.
3. _prompt_model_selection's priced-model header still used pad=5
from the old simple_term_menu layout (3-char cursor + 2-char item
prefix). curses_single_select renders rows as " {arrow} {label}"
(3-char prefix), so the header's In/Out labels were shifted past
their values. Pad is now 3, and a regression test verifies header
right-edge alignment with the first priced column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up round 3 — self-review caught three more regressions:
All 45 curses-UI / session-browse / reasoning-effort-menu tests pass locally. |
The previous commit short-circuited curses_single_select() to return None when stdin is not a TTY, on the theory that matching curses_checklist's cancel-on-headless behavior was safer. It is not: several selection flows are exercised in tests and scripted wrappers by piping a numeric choice through stdin (test_terminal_menu_fallbacks and test_custom_provider_model_switch drive _prompt_model_selection(), _prompt_reasoning_effort_selection(), _remove_custom_provider(), and _model_flow_named_custom() this way). Returning None surfaced as a silent "Cancelled." for every one of those paths. Go back to dispatching into _numbered_single_fallback(): it prints the numbered prompt, reads one line via input(), and already catches EOFError so a truly detached stdin still yields a clean cancel without blocking. Added two regression tests — one asserts a piped "2" selects index 1, the other asserts EOFError surfaces as None. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up round 4 — self-review caught a P1 regression that the previous round introduced: The previous "make curses_single_select return None on non-TTY" change (meant to match Reverted to dispatching into Added two regression tests: one asserts a piped Honest lesson from this round: the "simple" arrow-key fix is really a |
1. _prompt_model_selection wrapped the entire curses flow (including the custom-model input() call) in a broad except-Exception. Ctrl-D / pipe-close during "Enter model name" raised EOFError, which was swallowed and the function fell through to the numbered fallback, redrawing the whole menu. EOF now cancels cleanly by catching EOFError/KeyboardInterrupt at the input call. 2. curses_single_select subtracted footer_shown from visible_rows but rendered the footer starting at last_item_row + 2, which costs a blank separator row that was not budgeted. On a 6-row tmux split the reserved-but-never-rendered footer slot shrank the selectable list for no visible gain. Now add the separator to the budget and skip the footer entirely when the terminal is too short to fit even one footer row after the separator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Follow-up round 5 — two P3 fixes:
Added two regression tests: one asserts that an EOF during custom-entry returns |
What changed
KEY_UP/KEY_DOWNsimple_term_menuonto the shared curses menu helpersWhy
Some terminals were sending arrow keys as raw escape sequences such as
ESC [ B, so the initial provider picker could appear to work while later model-selection menus treated navigation input incorrectly. This madehermes modeland related setup flows unreliable.Impact
simple_term_menuRoot cause
The shared menu code only handled
curses.KEY_*values, and some later pickers still usedsimple_term_menu. In affected terminals that split arrows into escape sequences, the menus interpreted the input incorrectly.Validation
./.venv/bin/python -m pytest tests/hermes_cli/test_curses_ui_navigation.py tests/hermes_cli/test_session_browse.py tests/hermes_cli/test_setup_prompt_menus.py tests/hermes_cli/test_plugins_cmd.py -q