diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index ee31183b7..a9bd4626d 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -5,11 +5,120 @@ Provides a curses multi-select with keyboard navigation, plus a text-based numbered fallback for terminals without curses support. """ import sys +from dataclasses import dataclass from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color +def _query_matches(label: str, query: str) -> bool: + """Return True when every query token is a case-insensitive subsequence.""" + normalized = label.lower() + tokens = query.lower().split() + + if not tokens: + return True + + for token in tokens: + pos = 0 + + for ch in token: + pos = normalized.find(ch, pos) + + if pos < 0: + return False + + pos += 1 + + return True + + +def _filter_indices(items: List[str], query: str) -> List[int]: + """Return original item indices matching *query*, preserving list order.""" + q = query.strip() + + if not q: + return list(range(len(items))) + + return [i for i, label in enumerate(items) if _query_matches(label, q)] + + +@dataclass +class _SearchState: + """Mutable search state shared by curses picker loops.""" + + active: bool = False + query: str = "" + + +def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]: + """Return ``(cursor, cursor_pos)`` inside the filtered index list.""" + if not filtered: + return cursor, 0 + + if cursor not in filtered: + cursor = filtered[0] + + return cursor, filtered.index(cursor) + + +def _move_filtered_cursor( + filtered: List[int], cursor: int, cursor_pos: int, delta: int +) -> int: + """Move through the filtered index list, wrapping like the legacy menus.""" + if not filtered: + return cursor + + return filtered[(cursor_pos + delta) % len(filtered)] + + +def _scroll_for_cursor( + scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int +) -> int: + """Clamp scroll offset so the cursor remains visible.""" + visible_rows = max(1, visible_rows) + + if cursor_pos < scroll_offset: + scroll_offset = cursor_pos + elif cursor_pos >= scroll_offset + visible_rows: + scroll_offset = cursor_pos - visible_rows + 1 + + return max(0, min(scroll_offset, max(0, total_rows - visible_rows))) + + +def _handle_active_search_key( + curses_mod, key: int, search: _SearchState +) -> tuple[bool, bool, bool]: + """Handle a key while the search prompt is active. + + Returns ``(handled, confirm, changed)``. Active search consumes query + editing keys, but leaves navigation keys for the menu loop to handle. + """ + if not search.active: + return False, False, False + + if key == 27: + search.active = False + return True, False, False + + if key in (curses_mod.KEY_BACKSPACE, 127, 8): + search.query = search.query[:-1] + return True, False, True + + if key == 21: # Ctrl+U + search.query = "" + return True, False, True + + if key in (curses_mod.KEY_ENTER, 10, 13): + return True, True, False + + if 0 <= key < 256 and chr(key).isprintable(): + search.query += chr(key) + return True, False, True + + return False, False, False + + def flush_stdin() -> None: """Flush any stray bytes from the stdin input buffer. diff --git a/tests/hermes_cli/test_curses_ui_search.py b/tests/hermes_cli/test_curses_ui_search.py new file mode 100644 index 000000000..877240fc3 --- /dev/null +++ b/tests/hermes_cli/test_curses_ui_search.py @@ -0,0 +1,68 @@ +from hermes_cli.curses_ui import ( + _SearchState, + _filter_indices, + _handle_active_search_key, + _move_filtered_cursor, + _reconcile_cursor, +) + + +class _FakeCurses: + KEY_BACKSPACE = 263 + KEY_DOWN = 258 + KEY_ENTER = 343 + + +def test_filter_indices_keeps_all_items_for_blank_query(): + assert _filter_indices(["Anthropic", "OpenAI"], "") == [0, 1] + assert _filter_indices(["Anthropic", "OpenAI"], " ") == [0, 1] + + +def test_filter_indices_matches_subsequences(): + items = ["claude-opus-4-7", "gpt-5.4-codex", "deepseek-v4"] + + assert _filter_indices(items, "co47") == [0] + assert _filter_indices(items, "gpt5") == [1] + + +def test_filter_indices_requires_all_tokens(): + items = ["OpenAI Codex", "OpenAI Chat Completions", "Anthropic Claude"] + + assert _filter_indices(items, "open cod") == [0] + + +def test_reconcile_cursor_moves_to_first_visible_match(): + assert _reconcile_cursor([2, 4], 0) == (2, 0) + assert _reconcile_cursor([2, 4], 4) == (4, 1) + + +def test_move_filtered_cursor_wraps_within_matches(): + filtered = [2, 4, 7] + + assert _move_filtered_cursor(filtered, 2, 0, -1) == 7 + assert _move_filtered_cursor(filtered, 7, 2, 1) == 2 + + +def test_active_search_allows_navigation_keys_to_reach_menu_loop(): + search = _SearchState(active=True, query="opus") + + assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_DOWN, search) == ( + False, + False, + False, + ) + assert search.active is True + assert search.query == "opus" + + +def test_active_search_consumes_query_editing_and_confirm_keys(): + search = _SearchState(active=True, query="op") + + assert _handle_active_search_key(_FakeCurses, ord("u"), search) == (True, False, True) + assert search.query == "opu" + + assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_ENTER, search) == ( + True, + True, + False, + )