feat(cli): add fuzzy search helpers for curses pickers
Pure, refactor-independent helpers for type-to-filter search in the curses single-/radio-select menus: subsequence matching, filtered-index mapping, cursor reconciliation, scroll clamping, and an active-search key handler, plus unit tests. Salvaged from #22758 (the curses event loop was since refactored into a shared driver on main, so the integration is rebuilt in a follow-up commit; these pure helpers and their tests carry over unchanged).
This commit is contained in:
@ -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.
|
||||
|
||||
|
||||
68
tests/hermes_cli/test_curses_ui_search.py
Normal file
68
tests/hermes_cli/test_curses_ui_search.py
Normal file
@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user