Skip to content

fix(cli): drain stdin on every curses picker exit, and stop raw arrow keys clearing the search query - #83045

Open
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/cli-curses-picker-drain-stdin-7167
Open

fix(cli): drain stdin on every curses picker exit, and stop raw arrow keys clearing the search query#83045
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/cli-curses-picker-drain-stdin-7167

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

This is a sibling follow-up to #7167 (merged 2026-04-10)

What does this PR do?

Two bugs in hermes_cli/curses_ui.py's shared picker driver, _run_curses_menu,
both about raw escape bytes.

1. flush_stdin() only ran on the normal return path.

        curses.wrapper(_draw)
        flush_stdin()
        return result_holder[0] if result_holder[0] is not _KEEP else cancel_value

    except KeyboardInterrupt:
        return cancel_value
    except Exception:
        return fallback()

The invariant is stated in this file, by flush_stdin's own docstring: it
"must be called after curses.wrapper()before the next input() /
getpass.getpass() call"
, because curses.endwin() restores the terminal but
does not drain the OS input buffer, so leftover escape-sequence bytes
"silently get consumed by the next input() call, corrupting user data".

To be precise about the blast radius: curses.wrapper's teardown is
keypad(0); echo(); nocbreak(); endwin(), so terminal modes are restored
correctly. This is not a "terminal left unusable" bug. The claim is
narrower and concrete: buffered bytes leak into the next prompt.

The fallback path is where that bites hardest. All three numbered fallbacks
call input() as their next real statement — _radio_numbered_fallback,
_numbered_single_fallback, _numbered_fallback — so the one code path whose
entire job is "curses died, let the user type a number instead" goes from
curses teardown straight to input() with the buffer undrained. Stray bytes
are read as the answer, int(val) raises ValueError, and the picker silently
resolves to its cancel value without the user having answered. On the
Ctrl+C path the bytes survive into whatever the caller prompts for next.

Fixed by wrapping the wrapper call in try/finally. import curses stays
outside the inner try so an ImportError on Windows never triggers a
pointless flush, and flush_stdin already no-ops on non-TTY stdin and swallows
its own exceptions, so it cannot mask the error that is unwinding.

2. A raw arrow key during an active search was read as "cancel the search".

The driver reads key = stdscr.getch() and, when the type-to-filter prompt is
open, hands that raw byte to _handle_active_search_key, which treats any 27
as a lone ESC and wipes search.query.

_decode_menu_key exists in this same module precisely because some
terminals/terminfo entries deliver cursor keys as raw CSI/SS3 sequences even
with keypad(True)getch() returns 27, then [, then A/B — and it
disambiguates with a 60 ms continuation probe. The search branch ran no such
probe. Repro on an affected terminal: hermes model/ → type sonnet
press ↓. The query vanishes, the full list snaps back, and the cursor does not
move (the [ and B tail bytes decode to NAV_NONE and are swallowed).

Now the driver decodes a 27 first and only routes a genuine NAV_CANCEL
(probe timed out ⇒ real lone ESC) to _handle_active_search_key. A decoded
arrow becomes the loop's action, so the cursor moves through the filtered list
with the query intact. _handle_active_search_key keeps its signature and its
lone-ESC semantics, which are still covered.

Related Issue

No filed issue. Direct sibling completion of merged #7167; the interrupt-path
variant of the same drain already merged in #54058 (flush_stdin() on the
turn-interrupt path in cli.py). The raw-CSI terminal population is
repo-acknowledged — merged #35806 ("ESC + Ghostty") and the dedicated
tests/hermes_cli/test_curses_arrow_keys.py.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/curses_ui.py_run_curses_menu: curses.wrapper(_draw) now
    runs under try/finally with flush_stdin() in the finally, so the
    KeyboardInterrupt and curses-error paths drain too.
  • hermes_cli/curses_ui.py_run_curses_menu: while search is active, a raw
    27 is decoded via _decode_menu_key before _handle_active_search_key is
    allowed to treat it as a lone ESC; the already-consumed sequence is never
    decoded twice.
  • tests/hermes_cli/test_curses_ui_teardown.py (new) — five regression tests,
    three of which fail on main today.

Sibling sweep — deliberately not changed

There are three production curses.wrapper(...) sites. This PR fixes the
shared driver only; the other two are left alone on purpose because I already
have open PRs holding those files
, and stacking an unrelated change into them
would make both harder to review:

site disposition
hermes_cli/curses_ui.py _run_curses_menu fixed here
hermes_cli/plugins_cmd.py (curses.wrapper + flush_stdin on the normal path only) not touched — file held by my open #80162
hermes_cli/main.py _session_browse_picker (never calls flush_stdin on any path) not touched — file held by my open #58336

Both have the same defect and are worth a follow-up; I did not want to bundle
them.

Related / Positioning

How to Test

Automated:

pytest tests/hermes_cli/test_curses_ui_teardown.py -v

Before/after on clean main (56dc01d904d), three of the five are red:

test on main with this PR
test_flush_stdin_runs_when_picker_is_interrupted FAIL — assert [] == ['flush'] PASS
test_flush_stdin_runs_before_the_numbered_fallback FAIL — assert [0] == [1] (fallback sees zero drains) PASS
test_raw_arrow_escape_does_not_wipe_the_active_search_query FAIL — assert 1 == 2 (query wiped, cursor never moved) PASS
test_flush_stdin_still_runs_on_the_normal_path PASS (guard) PASS
test_lone_escape_still_stops_the_search_and_restores_the_full_list PASS (guard) PASS

The two guards are deliberately green in both directions: one catches a
regression that moves the drain into the exception handlers instead of
widening it to every path, the other pins that a genuine lone ESC still stops
the search and restores the full list.

Manual (bug 2), on a terminal that emits raw CSI cursor keys (Ghostty, some
tmux/SSH terminfo combinations):

  1. hermes model
  2. Press /, type sonnet.
  3. Press ↓.
  4. Before: the filter is cleared and the whole list returns. After: the cursor
    moves to the next match and Search: sonnet stays on the hint line.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11

I ran the curses/picker-adjacent suites rather than the full tree:
test_curses_ui_teardown.py, test_curses_ui_search.py, test_curses_arrow_keys.py,
test_curses_ui_fuzzy_rank.py, test_curses_color_compat.py,
test_setup_menu_curses_migration.py, test_reasoning_effort_menu.py,
test_custom_provider_model_switch.py, test_model_switch_custom_providers.py,
test_plugins_cmd.py, test_session_browse.py — 126 passed.

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Windows: import curses stays outside the new inner try, so the ImportError
path is unchanged and no flush is attempted; flush_stdin is already a no-op
there. The new test module carries the same module-level sys.platform == "win32" skip as test_curses_arrow_keys.py.

`_run_curses_menu` called `flush_stdin()` only on the normal return path.
Both abnormal exits — `except KeyboardInterrupt` and the `except Exception`
branch that hands off to the numbered fallback — skipped it.

`flush_stdin`'s own docstring states the invariant this violates: it "must be
called after `curses.wrapper()` ... before the next `input()` /
`getpass.getpass()` call", because `curses.endwin()` restores terminal modes
but does not drain the OS input buffer. Leftover CSI bytes from arrow keys or
rapid keypresses survive into the next `input()` and are silently consumed as
if the user had typed them.

The fallback path is where this bites hardest: `_radio_numbered_fallback`,
`_numbered_single_fallback` and `_numbered_fallback` all call `input()` as
their next real statement. So the one code path whose entire job is "curses
died, let the user type a number instead" goes straight from curses teardown
to `input()` with the buffer undrained — `int(val)` then raises `ValueError`
and the picker resolves to its cancel value without the user having answered.

Wrapping the `curses.wrapper()` call in `try/finally` drains on all three
paths. `import curses` deliberately stays outside the inner `try` so an
ImportError on Windows never triggers a pointless flush, and `flush_stdin`
already no-ops on non-TTY stdin and swallows its own errors, so it cannot
mask the exception that is unwinding.
…s picker

While the type-to-filter prompt is open, the driver hands the raw `getch()`
byte straight to `_handle_active_search_key`, which treats any `27` as a lone
ESC and clears `search.query`.

But `27` is ambiguous. `_decode_menu_key` exists in this same module precisely
because some terminals/terminfo entries deliver cursor keys as raw CSI/SS3
sequences even with `keypad(True)` — `getch()` returns `27`, then `[`, then
`A`/`B` — and it disambiguates with a 60 ms continuation probe. The search
branch did no such probe, so on those terminals an arrow key inside an active
search was read as "stop searching".

Symptom: in `hermes model`, press `/`, type `sonnet`, press the down arrow.
The query vanishes, the full unfiltered list snaps back, and the cursor does
not move — the `[` and `A`/`B` tail bytes then decode to NAV_NONE and are
swallowed.

Fix: when search is active and the key is `27`, run `_decode_menu_key` first.
Only a genuine `NAV_CANCEL` (the probe timed out, i.e. a real lone ESC) reaches
`_handle_active_search_key`; a decoded arrow becomes the loop's action so the
cursor moves through the filtered list with the query intact. The already
consumed sequence is never decoded twice. `_handle_active_search_key` keeps its
signature and its lone-ESC semantics.
New file rather than an edit to the existing curses test modules, so the
regression coverage does not collide with unrelated in-flight work on them.

Covers both invariants and both directions:

- `test_flush_stdin_runs_when_picker_is_interrupted` — `curses.wrapper` raises
  KeyboardInterrupt; asserts the cancel value AND exactly one drain.
- `test_flush_stdin_runs_before_the_numbered_fallback` — `curses.wrapper`
  raises `curses.error`; the fallback spy records how many drains it can see at
  the moment it is invoked, so this asserts ORDERING, not just a count. This is
  the load-bearing case: the fallback's next statement is `input()`.
- `test_flush_stdin_still_runs_on_the_normal_path` — guards against a
  regression that MOVES the drain into the exception handlers instead of
  widening it to all paths.
- `test_raw_arrow_escape_does_not_wipe_the_active_search_query` — replays the
  raw three-byte arrow-down `ESC [ B` during an active search and asserts the
  cursor moved within the filtered list rather than the query being cleared.
- `test_lone_escape_still_stops_the_search_and_restores_the_full_list` — a
  genuine lone ESC (continuation probe reads -1) keeps its existing meaning.

Reuses the module-level `sys.platform == "win32"` skip and the `FakeStdscr`
replay pattern already established in `tests/hermes_cli/test_curses_arrow_keys.py`.
Copilot AI lite review requested due to automatic review settings August 10, 2026 08:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the shared curses picker driver (_run_curses_menu) against raw escape-byte leakage and mis-decoding during active search, ensuring terminal input remains clean and cursor-key navigation works reliably across terminals that emit raw CSI/SS3 sequences.

Changes:

  • Move flush_stdin() into a finally so stdin is drained on normal return, KeyboardInterrupt, and curses-error fallback paths.
  • When search is active, decode a raw ESC (27) via _decode_menu_key before treating it as “lone ESC clears search,” preventing arrow keys from wiping the query.
  • Add a new regression test module covering teardown draining and search + raw-arrow behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
hermes_cli/curses_ui.py Ensures stdin is flushed on every wrapper exit and fixes raw-ESC handling during active search by routing through _decode_menu_key.
tests/hermes_cli/test_curses_ui_teardown.py Adds regression tests proving flush ordering on abnormal exits and preserving search query on raw CSI arrow sequences.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have labels Aug 10, 2026
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 P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants