feat(cli): ranked fuzzy search in the curses model picker

Wires the salvaged search helpers into the shared curses menu driver and
turns on type-to-filter for the CLI model pickers (the 100+ model lists
that previously required scrolling).

- Search lives in the shared `_run_curses_menu` driver behind a
  `searchable` flag + `search_labels`, so both `curses_radiolist` and
  `curses_single_select` get it without per-menu duplication. `/` opens
  the filter, BACKSPACE edits, Ctrl+U clears, ESC clears the filter then
  cancels. Returned values are always original item indices.
- `_filter_indices` RANKS matches (best-first) via a Python port of the
  TS scorer in ui-tui/src/lib/fuzzy.ts and web/src/lib/fuzzy.ts. The port
  is byte-identical in score: same per-char bonuses, prefix (+8) and
  exact (+20) bonuses, camelCase/word-boundary detection (matching on the
  lowercased target, boundary on the original case), and the -len*0.01
  length tiebreak — so the CLI, TUI, and WebUI rank results identically.
  A cross-language parity test pins the exact scores.
- `_prompt_model_selection` (the canonical picker across the model flows)
  and the custom-provider model list pass `searchable=True`.
- Split `_decode_menu_key` out of `read_menu_key` so the search loop can
  peek the raw key (catch `/`) before nav decoding.
- ESC during active search now clears the query (restores the full list)
  so a no-match filter can't strand the user; printable-key capture is
  restricted to ASCII to avoid Latin-1 mojibake.
- Update two setup-menu tests whose mock signatures predate the new
  `searchable` kwarg; add ranked-scorer + parity + state-machine tests.
This commit is contained in:
kshitijk4poor
2026-06-01 16:58:58 -07:00
committed by Teknium
parent 53f598e7a2
commit 0fdab53ef0
5 changed files with 375 additions and 33 deletions
@@ -13,7 +13,7 @@ def test_prompt_model_selection_uses_curses_radiolist():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
seen["title"] = title
seen["items"] = items
return 1 # pick second model
@@ -67,7 +67,7 @@ def test_model_selection_with_pricing_passes_description():
seen = {}
def _fake(title, items, *, selected=0, cancel_returns=None, description=None):
def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False):
seen["description"] = description
return len(items) - 1 # Skip