Skip to content

fix(cli): migrate setup model/provider pickers off simple_term_menu to curses (ESC + Ghostty) - #35795

Closed
kshitijk4poor wants to merge 2 commits into
NousResearch:mainfrom
kshitijk4poor:fix/setup-menus-curses-migration
Closed

fix(cli): migrate setup model/provider pickers off simple_term_menu to curses (ESC + Ghostty)#35795
kshitijk4poor wants to merge 2 commits into
NousResearch:mainfrom
kshitijk4poor:fix/setup-menus-curses-migration

Conversation

@kshitijk4poor

@kshitijk4poor kshitijk4poor commented May 31, 2026

Copy link
Copy Markdown
Collaborator

What

hermes setup → select a provider → enter the model selection sub-menu → ESC doesn't back out. Same problem affects three sibling pickers. Root cause: these four menus use simple_term_menu.TerminalMenu, whose ESC/arrow-key handling is unreliable across terminals — it conflicts with /dev/tty and, on terminals that emit raw escape sequences (e.g. Ghostty), ESC and arrows misbehave.

This is the same class of bug fixed for the curses menus in #35776 / #35786, but these four sites were never on the curses path.

This PR has two commits

1. fix(cli): migrate the 4 simple_term_menu pickers to curses_radiolist (the hardened helper from #35776, decodes raw CSI/SS3 escapes, consistent ESC):

Picker Location
Model selection (the reported bug) auth.py _prompt_model_selection
Reasoning effort main.py _prompt_reasoning_effort_selection
Named custom-provider model main.py _model_flow_named_custom
Remove custom provider main.py _remove_custom_provider

The model picker's pricing column header and unavailable-models block are passed as the radiolist description so they survive the curses clear. simple_term_menu is no longer imported anywhere.

2. refactor(cli): extract a shared curses menu event-loop driver. The three curses menus (curses_checklist / curses_radiolist / curses_single_select) each hand-rolled an identical loop: color-pair init, clear/getmaxyx/refresh, scroll-offset math, the read_menu_key dispatch + NAV_UP/DOWN wrap, flush_stdin, and the fallback. A terminal-behavior change (Ghostty escapes, scroll, a new key) had to be made in three places.

_run_curses_menu now owns that boilerplate. Each menu supplies small callbacks for what genuinely differs: draw_header (returns the item-list start row), draw_row ([✓] vs (●) vs bare), an on_action reducer, an optional draw_footer (the checklist status bar), reserve_bottom, and the numbered fallback. Duplicated event-loop primitives drop 3 → 1. Future Ghostty/terminal work is a one-place edit.

Validation

  • Byte-identical refactor proof: a render harness records every addnstr(y, x, clamped-text, attr) call across frames + the return value for 6 cases (checklist, checklist+status, radiolist, radiolist+description, single_select, single_select ESC-cancel). Output diffs clean against origin/main.
  • Non-TTY returns the cancel value directly (not the input()-based numbered fallback), matching the old per-menu guard — fixing this also cleared a test-isolation failure where an errant input() polluted later setup tests.
  • New test_setup_menu_curses_migration.py asserts each picker routes through curses_radiolist, ESC cancels, and the pricing header is preserved.
  • Updated test_terminal_menu_fallbacks / test_reasoning_effort_menu / test_custom_provider_model_switch / test_model_provider_persistence to drive the numbered fallback via a curses_radiolist error (the mock target moved off simple_term_menu).
  • 150 menu/setup/browse/plugins tests pass; broader curses/menu/tools/skills sweep 563 pass. Full suite confirmation deferred to CI.

Net production code shrinks the duplicated surface (3→1 event loops); raw line count is roughly flat because the new driver + docstrings offset the removed inline loops — the win is maintainability, not LOC.

Note: #35786 also rewrites these curses_ui.py loops (it added read_menu_key_ex + paging). Whichever merges second will need a rebase on top of the other — I'll handle that.

…o curses

The setup provider->model sub-menu (and three sibling pickers) used
simple_term_menu.TerminalMenu, whose ESC and arrow-key handling was
unreliable across terminals — notably ESC failed to back out of the
model selection list on terminals that emit raw escape sequences (e.g.
Ghostty). The codebase already notes simple_term_menu 'conflicts with
/dev/tty' and causes 'ghost-duplication rendering', and a prior attempt
to migrate these (closed PR) confirmed the same root cause.

Route all four single-select pickers through the shared, already-hardened
curses_radiolist (which decodes raw CSI/SS3 escape sequences and handles
ESC consistently, fixed in NousResearch#35776):

- auth.py _prompt_model_selection — model picker; the pricing column
  header and the unavailable-models block are passed as the radiolist
  description so they survive the curses screen clear. ESC now cancels.
- main.py _prompt_reasoning_effort_selection — reasoning-effort picker.
- main.py _model_flow_named_custom — named custom-provider model picker.
- main.py _remove_custom_provider — provider-removal picker.

simple_term_menu is no longer imported anywhere (only stale comments
referenced it; one in setup.py is corrected). The numbered-input
fallbacks are unchanged and still trigger on curses errors / non-TTY.

Tests: updated test_terminal_menu_fallbacks / test_reasoning_effort_menu
/ test_custom_provider_model_switch / test_model_provider_persistence to
drive the fallback via curses_radiolist errors instead of breaking
simple_term_menu. New test_setup_menu_curses_migration.py asserts each
picker routes through curses_radiolist, ESC cancels, and the pricing
header is preserved. Net -147/+183 (mostly the new test file; production
code shrinks by removing TerminalMenu boilerplate).
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists labels May 31, 2026
The three curses menus (curses_checklist / curses_radiolist /
curses_single_select) each hand-rolled an identical event loop: cursor
hide + color-pair init, the per-frame clear/getmaxyx/refresh cycle,
scroll-offset math, row iteration, the read_menu_key dispatch with
NAV_UP/NAV_DOWN cursor wrap, flush_stdin, and the
KeyboardInterrupt/curses-unavailable fallback. Terminal-behavior changes
(e.g. Ghostty raw-escape handling, scroll tweaks, a new key) had to be
made in three places.

Extract that boilerplate into one _run_curses_menu driver. Each public
menu now supplies small callbacks for the parts that genuinely differ:
draw_header (returns the item-list start row), draw_row (checkbox vs
radio vs bare prefix), an on_action reducer (toggle-set vs return-cursor
vs return-None + the single_select cancel-row guard), an optional
draw_footer (the checklist status bar), reserve_bottom, and the numbered
fallback. Behavior is passed as functions; the loop is the only stateful
piece — so future terminal/Ghostty work is a one-place edit.

Duplicated event-loop primitives drop 3 -> 1 (stdscr.clear, read_menu_key
dispatch, scroll math). Verified byte-identical: a render harness records
every addnstr(y, x, clamped-text, attr) call across frames plus the
return value for 6 cases (checklist, checklist+status, radiolist,
radiolist+description, single_select, single_select ESC-cancel); output
diffs clean against origin/main. Non-TTY returns the cancel value
directly (not the input()-based numbered fallback), matching the old
per-menu guard. 150 menu/setup/browse/plugins tests pass.
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #35806#35806

Both your commits were cherry-picked onto current main (rebase-merge), so your authorship is preserved per-commit:

  • 087be0073 fix(cli): migrate setup model/provider pickers off simple_term_menu to curses
  • 8f4c8e7c8 refactor(cli): extract shared curses menu event-loop driver

CI was fully green (all 6 test shards + builds + e2e + lint + nix). Thanks for the clean fix and the byte-identical render-harness proof on the refactor.

Heads up: #35786 also rewrites these curses_ui.py loops — it'll need a rebase on top of this now.

@teknium1 teknium1 closed this May 31, 2026
@kshitijk4poor
kshitijk4poor deleted the fix/setup-menus-curses-migration branch August 5, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants