Files
hermes-agent/hermes_cli/curses_ui.py

977 lines
35 KiB
Python

"""Shared curses-based UI components for Hermes CLI.
Used by `hermes tools` and `hermes skills` for interactive checklists. Provides a curses multi-
select with keyboard navigation, plus a text-based numbered fallback for terminals without curses
support.
"""
import sys
from contextvars import ContextVar, Token
from dataclasses import dataclass
from enum import Enum
from typing import Callable, List, Optional, Protocol, Sequence, Set, Tuple, Union
from hermes_cli.colors import Colors, color
# Rich radiolist rows: (text, style). style is None | "yellow" | "dim".
# Plain ``str`` items remain fully supported.
RadioItem = Union[str, Sequence[Tuple[str, Optional[str]]]]
_NO_REPLAY = object()
@dataclass(frozen=True)
class MenuNavigationStart:
"""Navigation instructions returned when a scoped menu begins."""
allow_back: bool = False
replay_value: object = _NO_REPLAY
@property
def should_replay(self) -> bool:
return self.replay_value is not _NO_REPLAY
class MenuNavigationEvent(str, Enum):
BEGIN = "begin"
RESOLVE = "resolve"
CANCEL = "cancel"
BACK = "back"
class MenuNavigationHandler(Protocol):
"""Typed contract between shared menus and a scoped flow controller."""
def __call__(self, event: MenuNavigationEvent, value: object = None) -> MenuNavigationStart | None: ...
_MENU_NAVIGATION_HANDLER: ContextVar[MenuNavigationHandler | None] = ContextVar(
"hermes_menu_navigation_handler", default=None
)
_NUMBERED_BACK_ENABLED: ContextVar[bool] = ContextVar("hermes_numbered_back_enabled", default=False)
def set_menu_navigation_handler(handler: MenuNavigationHandler) -> Token[MenuNavigationHandler | None]:
"""Scope setup-style cancel/back behavior to the current CLI invocation."""
return _MENU_NAVIGATION_HANDLER.set(handler)
def reset_menu_navigation_handler(token: Token[MenuNavigationHandler | None]) -> None:
"""Restore the menu navigation handler active before ``token``."""
_MENU_NAVIGATION_HANDLER.reset(token)
def _notify_scoped_navigation(event: MenuNavigationEvent) -> None:
"""Notify an active menu flow that a text fallback was interrupted (CANCEL) or requested BACK."""
handler = _MENU_NAVIGATION_HANDLER.get()
if handler is not None:
handler(event)
class _NumberedNavigation(Enum):
CANCEL = "cancel"
BACK = "back"
_NAV_ABORT = object()
def _read_numbered_choice(prompt_text: str) -> int | None | object:
"""Read a numbered fallback choice as a 0-based index.
Returns ``None`` for empty input and ``_NAV_ABORT`` when the prompt was cancelled, backed out
of, interrupted, or given a non-integer (scoped navigation is notified for cancel/back).
"""
try:
val = _read_numbered_input(prompt_text)
except (KeyboardInterrupt, EOFError):
_notify_scoped_navigation(MenuNavigationEvent.CANCEL)
return _NAV_ABORT
if isinstance(val, _NumberedNavigation):
_notify_scoped_navigation(MenuNavigationEvent(val.value))
return _NAV_ABORT
if not val.strip():
return None
idx = _parse_int(val.strip(), default=None)
return _NAV_ABORT if idx is None else idx - 1
def _read_numbered_input(prompt_text: str) -> str | _NumberedNavigation:
"""Read a numbered fallback choice with setup navigation key bindings.
Ordinary numbered menus retain their historical ``input()`` behavior. During setup/model flows,
prompt_toolkit supplies portable Escape, Ctrl+C, and Left bindings on POSIX and native Windows
when curses is unavailable.
"""
if _MENU_NAVIGATION_HANDLER.get() is None:
return input(prompt_text)
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
# Setup can be invoked without importing the classic CLI, which normally
# installs Ghostty/Kitty CSI-u aliases at process startup.
from hermes_cli.pt_input_extras import install_modify_other_keys_aliases
install_modify_other_keys_aliases()
bindings = KeyBindings()
@bindings.add(Keys.Escape)
@bindings.add(Keys.ControlC)
def _cancel(event) -> None:
event.app.exit(result=_NumberedNavigation.CANCEL)
if _NUMBERED_BACK_ENABLED.get():
@bindings.add(Keys.Left)
def _back(event) -> None:
event.app.exit(result=_NumberedNavigation.BACK)
return PromptSession().prompt(ANSI(prompt_text), key_bindings=bindings)
def radio_item_plain(item: RadioItem) -> str:
"""Flatten a radiolist item to searchable/plain display text."""
if isinstance(item, str):
return item
return "".join(text for text, _style in item)
def _curses_style_attr(curses, style: Optional[str], *, is_cursor: bool):
"""Map a segment style to a curses attribute."""
has_colors = curses.has_colors()
if is_cursor:
return curses.A_BOLD | (curses.color_pair(1) if has_colors else 0)
if style == "yellow" and has_colors:
return curses.color_pair(2)
if style == "dim":
attr = curses.A_DIM
if has_colors:
# Pair 3 is the dim-gray status color (extra_color_pairs).
try:
attr |= curses.color_pair(3)
except curses.error:
pass
return attr
return curses.A_NORMAL
def _addnstr(stdscr, y: int, x: int, text: str, n: int, attr) -> None:
"""``stdscr.addnstr`` that swallows ``curses.error`` (drawing past the screen edge)."""
import curses
try:
stdscr.addnstr(y, x, text, n, attr)
except curses.error:
pass
def _draw_title_and_hint(stdscr, title: str, hint: str, max_x: int, *, hint_row: int = 1) -> None:
"""Draw the bold/yellow menu title on row 0 and the dim key hint on ``hint_row``."""
import curses
hattr = curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0)
_addnstr(stdscr, 0, 0, title, max_x - 1, hattr)
_addnstr(stdscr, hint_row, 0, hint, max_x - 1, curses.A_DIM)
def _draw_plain_row(stdscr, y: int, line: str, max_x: int, *, is_cursor: bool) -> None:
"""Draw a plain menu row, bold green when it is the cursor row."""
import curses
_addnstr(stdscr, y, 0, line, max_x - 1, _curses_style_attr(curses, None, is_cursor=is_cursor))
def _draw_segments(stdscr, y: int, x: int, segments, max_x: int) -> None:
"""Draw ``(text, attr)`` segments left to right from column ``x``, clipped at the screen edge."""
col = x
for text, attr in segments:
remaining = max_x - 1 - col
if remaining <= 0:
break
chunk = text[:remaining]
_addnstr(stdscr, y, col, chunk, remaining, attr)
col += len(chunk)
def _draw_description_line(stdscr, y: int, text: str, max_x: int) -> None:
"""Draw a description line, highlighting ★ in yellow when colors exist."""
import curses
star_attr = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL
segments = []
for i, part in enumerate(text.split("★")):
if i:
segments.append(("★", star_attr))
if part:
segments.append((part, curses.A_NORMAL))
_draw_segments(stdscr, y, 0, segments, max_x)
def _draw_radio_item(stdscr, y: int, x: int, item: RadioItem, max_x: int, *, is_cursor: bool) -> None:
"""Draw a plain or segmented radiolist item starting at column ``x``."""
import curses
if isinstance(item, str):
attr = _curses_style_attr(curses, None, is_cursor=is_cursor)
_addnstr(stdscr, y, x, item, max(0, max_x - 1 - x), attr)
return
_draw_segments(
stdscr, y, x,
((text, _curses_style_attr(curses, style, is_cursor=is_cursor)) for text, style in item),
max_x,
)
_WORD_BOUNDARY = frozenset("-_/. ")
def _is_boundary(target: str, index: int) -> bool:
"""True if position ``index`` in ``target`` starts a word.
Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a separator char, or a
lower->upper camelCase transition.
"""
if index == 0:
return True
prev = target[index - 1]
if prev in _WORD_BOUNDARY:
return True
# camelCase / lower->upper transition (e.g. the `O` in `gptO`).
cur = target[index]
return prev == prev.lower() and cur != cur.lower() and cur == cur.upper()
def _token_score(orig: str, lower: str, token: str) -> float | None:
"""Score one token against a target. None if the token isn't a subsequence.
Faithful port of ``fuzzyScore`` in ui-tui and web ``fuzzy.ts`` so all three surfaces rank
model ids identically: contiguous runs, word-boundary/first-char starts, prefixes and exact
matches outrank scattered hits. Matching runs against ``lower`` while boundary detection
uses ``orig`` so the camelCase rule works, exactly as in the TS scorer.
"""
score = 0.0
prev = -1
search_from = 0
positions: list[int] = []
for ch in token:
idx = lower.find(ch, search_from)
if idx < 0:
return None
positions.append(idx)
score += 1
if prev >= 0 and idx == prev + 1:
score += 5
elif prev >= 0:
score -= min(idx - prev - 1, 3)
if _is_boundary(orig, idx):
score += 3
if idx == 0:
score += 5
prev = idx
search_from = idx + 1
# Prefix bonus: the token matched a contiguous prefix of the target.
if positions and positions[0] == 0 and positions[-1] == len(positions) - 1:
score += 8
# Exact full match dominates everything else.
if lower == token:
score += 20
# Slightly prefer shorter targets when scores are otherwise close.
score -= len(lower) * 0.01
return score
def _fuzzy_score(label: str, query: str) -> float | None:
"""Aggregate score for a multi-token query (AND). None if any token fails.
Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated token must match; per-
token scores are summed.
"""
lower = label.lower()
total = 0.0
for token in query.lower().split():
token_score = _token_score(label, lower, token)
if token_score is None:
return None
total += token_score
return total
def _filter_indices(items: List[str], query: str) -> List[int]:
"""Return item indices matching *query*, ranked best-first.
An empty query keeps every item in original order. Otherwise items are filtered to fuzzy matches
and sorted by score descending, ties broken by original index so equal-scoring rows keep their
catalog order.
"""
q = query.strip()
if not q:
return list(range(len(items)))
scored = [(i, score) for i, label in enumerate(items) if (score := _fuzzy_score(label, q)) is not None]
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return [i for i, _ in scored]
@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."""
return filtered[(cursor_pos + delta) % len(filtered)] if filtered else cursor
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."""
if not search.active:
return False, False, False
if key == 27:
# Esc stops search AND clears the query, restoring the full list (so a
# no-match filter can't strand the user on an empty list). Signals
# `changed` when there was a query so the driver resets scroll/cursor.
had_query = bool(search.query)
search.active = False
search.query = ""
return True, False, had_query
if key in (curses_mod.KEY_ENTER, 10, 13):
return True, True, False
if key in (curses_mod.KEY_BACKSPACE, 127, 8):
search.query = search.query[:-1]
elif key == 21: # Ctrl+U
search.query = ""
elif 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255
search.query += chr(key)
else:
return False, False, False
return True, False, True
def flush_stdin() -> None:
"""Flush any stray bytes from the stdin input buffer.
Must be called after ``curses.wrapper()`` returns, and before the next ``input()`` /
``getpass.getpass()`` call. ``curses.endwin()`` restores the terminal but does NOT drain the OS
input buffer.
"""
try:
if sys.stdin.isatty():
import termios
termios.tcflush(sys.stdin, termios.TCIFLUSH)
except Exception:
pass
# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
# every menu's key-handling branch identical and free of raw escape-byte logic.
NAV_UP = "up"
NAV_DOWN = "down"
NAV_BACK = "back"
NAV_SELECT = "select"
NAV_TOGGLE = "toggle"
NAV_CANCEL = "cancel"
NAV_INTERRUPT = "interrupt"
NAV_NONE = "none"
def read_menu_key(stdscr) -> str:
"""Read one keypress and normalize it to a menu action.
Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte within a short window)
is the only thing that maps to ``NAV_CANCEL`` via the escape path; ``q`` also cancels. Unknown
sequences map to ``NAV_NONE`` so the caller simply ignores them rather than misfiring.
"""
return _decode_menu_key(stdscr, stdscr.getch())
def _parse_int(value: str, default=0):
try:
return int(value)
except ValueError:
return default
def _parse_csi_u_key(raw: str) -> tuple[int, int, int] | None:
"""Parse a Kitty/CSI-u key into ``(codepoint, modifier, event_type)``; None without a codepoint."""
parts = raw.split(";")
codepoint = _parse_int(parts[0].split(":", 1)[0])
if not codepoint:
return None
mod_fields = parts[1].split(":") if len(parts) > 1 else []
modifier = _parse_int(mod_fields[0], 1) if mod_fields else 1
event_type = _parse_int(mod_fields[1], 1) if len(mod_fields) > 1 else 1
return codepoint, modifier, event_type
def _parse_csi_numbers(raw: str) -> list[int]:
"""Parse semicolon-delimited CSI numbers for modifyOtherKeys."""
return [_parse_int(part.split(":", 1)[0]) for part in raw.split(";")]
def _enhanced_key_action(codepoint: int, modifier: int = 1) -> str:
"""Map CSI-u/modifyOtherKeys codepoints to setup menu actions."""
if codepoint in (10, 13):
return NAV_SELECT
if codepoint == 27:
return NAV_CANCEL
if codepoint == 32:
return NAV_TOGGLE
# CSI-u encodes Ctrl+C as codepoint `c` plus the Ctrl modifier. Lock-state
# bits may be added to the modifier, so inspect the Ctrl bit rather than
# matching only the canonical value 5.
has_ctrl = bool((max(1, modifier) - 1) & 4)
if codepoint == 3 or (codepoint in (ord("c"), ord("C")) and has_ctrl):
return NAV_INTERRUPT
return NAV_NONE
def _read_csi_tail(stdscr) -> tuple[str, int | None]:
"""Read CSI/SS3 parameter bytes through the final byte."""
raw: list[str] = []
for _ in range(32):
value = stdscr.getch()
if 0x40 <= value <= 0x7E:
return "".join(raw), value
if not 0x20 <= value <= 0x3F:
break
raw.append(chr(value))
return "".join(raw), None
_CSI_FINAL_NAV = {ord("A"): NAV_UP, ord("k"): NAV_UP, ord("B"): NAV_DOWN, ord("j"): NAV_DOWN, ord("D"): NAV_BACK}
def _decode_menu_key(stdscr, key: int) -> str:
"""Normalize an already-read keypress to a menu action.
Split out from ``read_menu_key`` so search-aware loops can peek the raw key (e.g. to catch
``/``) before falling back to nav decoding.
"""
import curses
if key in (curses.KEY_UP, ord("k")):
return NAV_UP
if key in (curses.KEY_DOWN, ord("j")):
return NAV_DOWN
if key == curses.KEY_LEFT:
return NAV_BACK
if key == 3: # Ctrl+C in curses raw/cbreak mode.
return NAV_INTERRUPT
if key in (curses.KEY_ENTER, 10, 13):
return NAV_SELECT
if key == ord(" "):
return NAV_TOGGLE
if key == ord("q"):
return NAV_CANCEL
if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence.
# Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the
# bytes of an arrow key can arrive across separate reads, so a tiny
# timeout avoids misreading a split sequence as a bare ESC.
try:
stdscr.timeout(60)
nxt = stdscr.getch()
if nxt == -1:
return NAV_CANCEL # genuine lone ESC
if nxt in (ord("["), ord("O")): # CSI / SS3 introducer
raw_params, final = _read_csi_tail(stdscr)
if final in _CSI_FINAL_NAV:
return _CSI_FINAL_NAV[final]
if final == ord("u"):
enhanced = _parse_csi_u_key(raw_params)
if enhanced is not None:
codepoint, modifier, event_type = enhanced
if event_type == 3: # key release
return NAV_NONE
return _enhanced_key_action(codepoint, modifier)
if final == ord("~"):
params = _parse_csi_numbers(raw_params)
if len(params) >= 3 and params[0] == 27:
return _enhanced_key_action(params[2], params[1])
return NAV_NONE
# ESC followed by some other byte we don't handle — swallow it.
return NAV_NONE
finally:
stdscr.timeout(-1) # restore blocking mode
return NAV_NONE
# Sentinel: an on_action reducer returns this to mean "keep looping" (the
# keypress changed cursor/selection state but didn't resolve the menu).
_KEEP = object()
def _run_curses_menu(
*,
initial_cursor,
item_count,
draw_header,
draw_row,
on_action,
reserve_bottom=1,
draw_footer=None,
extra_color_pairs=False,
fallback,
cancel_value,
searchable=False,
search_labels=None,
):
"""Shared curses single-/multi-select event loop.
Owns the non-TTY guard, ``curses.wrapper`` setup, the per-frame clear/refresh cycle, scroll
math, key dispatch with cursor wrap, and the KeyboardInterrupt / curses-unavailable
fallback; per-menu behavior comes in as callbacks so rendering stays byte-identical to the
old hand-rolled loops. ``draw_row`` always receives the ORIGINAL item index (filtering
doesn't change rendering); ``on_action`` returns ``_KEEP`` to continue or any other value to
resolve the menu; a ``draw_footer`` row budget must be included in ``reserve_bottom``; with
``searchable``, ``/`` filters over ``search_labels`` (length must equal ``item_count``) and
results are original indices.
"""
navigation_handler = _MENU_NAVIGATION_HANDLER.get()
def _notify(event, *value):
if navigation_handler is not None:
navigation_handler(event, *value)
navigation_start = navigation_handler(MenuNavigationEvent.BEGIN) if navigation_handler else None
if navigation_start is not None and not isinstance(navigation_start, MenuNavigationStart):
raise TypeError("menu navigation 'begin' must return MenuNavigationStart")
allow_back = bool(navigation_start and navigation_start.allow_back)
if navigation_start is not None and navigation_start.should_replay:
_notify(MenuNavigationEvent.RESOLVE, navigation_start.replay_value)
return navigation_start.replay_value
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
# so return the cancel value directly — matching the pre-refactor guard in
# each menu (the numbered fallback is only for curses errors on a real TTY).
if not sys.stdin.isatty():
return cancel_value
use_search = searchable and search_labels is not None and len(search_labels) == item_count
def _run_fallback():
back_token = _NUMBERED_BACK_ENABLED.set(allow_back)
try:
result = fallback()
finally:
_NUMBERED_BACK_ENABLED.reset(back_token)
_notify(MenuNavigationEvent.RESOLVE, result)
return result
try:
import curses
except ImportError:
return _run_fallback()
try:
result_holder = [_KEEP]
def _resolve(outcome) -> bool:
"""Record a non-``_KEEP`` outcome; True when the menu is done."""
if outcome is _KEEP:
return False
_notify(MenuNavigationEvent.RESOLVE, outcome)
result_holder[0] = outcome
return True
def _draw(stdscr):
curses.curs_set(0)
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1)
curses.init_pair(2, curses.COLOR_YELLOW, -1)
if extra_color_pairs:
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1)
cursor = initial_cursor
scroll_offset = 0
search = _SearchState()
while True:
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
filtered = _filter_indices(search_labels, search.query) if use_search else list(range(item_count))
cursor, cursor_pos = _reconcile_cursor(filtered, cursor)
items_start = draw_header(stdscr, max_y, max_x, search=search, back_enabled=allow_back)
visible_rows = max(1, max_y - items_start - reserve_bottom)
scroll_offset = _scroll_for_cursor(scroll_offset, cursor_pos, visible_rows, len(filtered))
if use_search and search.query and not filtered:
_addnstr(stdscr, items_start, 0, " No matches", max_x - 1, curses.A_DIM)
for draw_i, i in enumerate(filtered[scroll_offset : scroll_offset + visible_rows]):
y = draw_i + items_start
if y >= max_y - reserve_bottom:
break
draw_row(stdscr, y, i, i == cursor, max_x)
if draw_footer is not None:
draw_footer(stdscr, max_y, max_x)
stdscr.refresh()
key = stdscr.getch()
if use_search and search.active and key == 27:
# Ghostty/Kitty enhanced keys also begin with ESC.
# Decode the full sequence before treating a genuine
# Escape as "stop search"; otherwise Enter/Left/Ctrl+C
# lose their tail while the search prompt is active.
action = _decode_menu_key(stdscr, key)
if action == NAV_CANCEL:
search.active = False
search.query = ""
scroll_offset = 0
continue
if action == NAV_NONE:
continue
elif use_search and search.active:
# Active search consumes query-editing keys; nav keys
# fall through to be decoded below.
handled, confirm, changed = _handle_active_search_key(curses, key, search)
if changed:
scroll_offset = 0
cursor, cursor_pos = _reconcile_cursor(
_filter_indices(search_labels, search.query), cursor
)
if confirm:
if filtered and _resolve(on_action(NAV_SELECT, cursor)):
return
continue
if handled:
continue
action = _decode_menu_key(stdscr, key)
elif use_search and key == ord("/"):
search.active = True
continue
else:
action = _decode_menu_key(stdscr, key)
if action == NAV_UP:
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1)
elif action == NAV_DOWN:
cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1)
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL, NAV_INTERRUPT) or (
action == NAV_BACK and allow_back
):
if action == NAV_SELECT and use_search and not filtered:
continue
if action in (NAV_CANCEL, NAV_INTERRUPT):
_notify(MenuNavigationEvent.CANCEL)
elif action == NAV_BACK:
_notify(MenuNavigationEvent.BACK)
if _resolve(on_action(action, cursor)):
return
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
except KeyboardInterrupt:
_notify(MenuNavigationEvent.CANCEL)
return cancel_value
except curses.error:
return _run_fallback()
def curses_checklist(
title: str,
items: List[str],
selected: Set[int],
*,
cancel_returns: Set[int] | None = None,
status_fn: Optional[Callable[[Set[int]], str]] = None,
) -> Set[int]:
"""Curses multi-select checklist. Returns set of selected indices.
``cancel_returns`` (default: the original *selected*) is returned on ESC/q.
``status_fn(chosen)`` renders on the bottom row for live aggregate info such as token
estimates.
"""
if cancel_returns is None:
cancel_returns = set(selected)
chosen = set(selected)
def _draw_row(stdscr, y, i, is_cursor, max_x):
check = "✓" if i in chosen else " "
arrow = "→" if is_cursor else " "
_draw_plain_row(stdscr, y, f" {arrow} [{check}] {items[i]}", max_x, is_cursor=is_cursor)
def _draw_footer(stdscr, max_y, max_x):
import curses
status_text = status_fn(chosen)
if status_text:
# Right-align on the bottom row
sx = max(0, max_x - len(status_text) - 1)
sattr = curses.A_DIM | (curses.color_pair(3) if curses.has_colors() else 0)
_addnstr(stdscr, max_y - 1, sx, status_text, max_x - sx - 1, sattr)
def _on_action(action, cursor):
if action == NAV_TOGGLE:
chosen.symmetric_difference_update({cursor})
return _KEEP
if action == NAV_SELECT:
return set(chosen)
return cancel_returns # NAV_CANCEL
return _run_curses_menu(
initial_cursor=0,
item_count=len(items),
draw_header=_simple_header(title, "SPACE toggle ENTER confirm", "ESC cancel", False),
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=(2 if status_fn else 1),
draw_footer=_draw_footer if status_fn else None,
extra_color_pairs=bool(status_fn),
fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn),
cancel_value=cancel_returns,
)
def _search_hint(search, searchable: bool, confirm: str, cancel: str, back_enabled: bool) -> str:
"""Key-hint row for menus, swapping to the search prompt while ``/`` is active."""
if searchable and search is not None and search.active:
hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop"
else:
hint = f" \u2191\u2193 navigate {confirm} {'/ search ' if searchable else ''}{cancel}"
if back_enabled:
hint += " \u2190 previous"
return hint
def _simple_header(title: str, confirm: str, cancel: str, searchable: bool):
"""``draw_header`` callback: title on row 0, key hint on row 1, items start on row 3."""
def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False):
hint = _search_hint(search, searchable, confirm, cancel, back_enabled)
_draw_title_and_hint(stdscr, title, hint, max_x)
return 3
return _draw_header
def curses_radiolist(
title: str,
items: List[RadioItem],
selected: int = 0,
*,
cancel_returns: int | None = None,
description: str | None = None,
searchable: bool = False,
search_labels: List[str] | None = None,
) -> int:
"""Curses single-select radio list. Returns the selected index.
Items are plain strings or ``(text, style)`` segment sequences
(``None``/``"yellow"``/``"dim"``); the cursor row is forced green, unselected rows honor
segment styles. ``description`` is shown between title and list so context survives the
curses screen clear. With ``searchable``, ``/`` filters over ``search_labels`` (default:
display labels) and the returned value is always the ORIGINAL item index, never a filtered
row position.
"""
if cancel_returns is None:
cancel_returns = selected
desc_lines = description.splitlines() if description else []
plain_labels = [radio_item_plain(item) for item in items] if searchable else None
def _draw_header(stdscr, max_y, max_x, search=None, back_enabled=False):
# Description lines — paint ★ yellow so the sale legend matches rows.
row = 1
for dline in desc_lines[: max(0, max_y - 2)]:
_draw_description_line(stdscr, row, dline, max_x)
row += 1
hint = _search_hint(search, searchable, "ENTER/SPACE select", "ESC cancel", back_enabled)
_draw_title_and_hint(stdscr, title, hint, max_x, hint_row=row)
# One blank row between the hint and the item list.
return row + 2
def _draw_row(stdscr, y, i, is_cursor, max_x):
radio = "\u25cf" if i == selected else "\u25cb"
arrow = "\u2192" if is_cursor else " "
prefix = f" {arrow} ({radio}) "
_draw_plain_row(stdscr, y, prefix, max_x, is_cursor=is_cursor)
_draw_radio_item(
stdscr, y, len(prefix), items[i], max_x, is_cursor=is_cursor
)
def _on_action(action, cursor):
if action in (NAV_SELECT, NAV_TOGGLE):
return cursor
return cancel_returns # NAV_CANCEL
return _run_curses_menu(
initial_cursor=selected,
item_count=len(items),
draw_header=_draw_header,
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
# Dim gray (pair 3) for unselected "was …" sale chrome.
extra_color_pairs=True,
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
cancel_value=cancel_returns,
searchable=searchable,
search_labels=(list(search_labels) if search_labels is not None else plain_labels) if searchable else None,
)
def format_radio_item_ansi(item: RadioItem) -> str:
"""Apply ANSI colors to a rich radiolist item (numbered fallback / prints)."""
if isinstance(item, str):
return item
return "".join(
color(text, _ANSI_STYLE[style]) if style in _ANSI_STYLE else text for text, style in item
)
_ANSI_STYLE = {"yellow": Colors.YELLOW, "dim": Colors.DIM}
def _radio_numbered_fallback(
title: str,
items: List[RadioItem],
selected: int,
cancel_returns: int,
) -> int:
"""Text-based numbered fallback for radio selection."""
print(color(f"\n {title}", Colors.YELLOW))
print(color(" Select by number, Enter to confirm.\n", Colors.DIM))
for i, label in enumerate(items):
marker = color("(\u25cf)", Colors.GREEN) if i == selected else "(\u25cb)"
print(f" {marker} {i + 1:>2}. {format_radio_item_ansi(label)}")
print()
idx = _read_numbered_choice(color(f" Choice [default {selected + 1}]: ", Colors.DIM))
if idx is _NAV_ABORT:
return cancel_returns
return idx if idx is not None and 0 <= idx < len(items) else selected
def curses_single_select(
title: str,
items: List[str],
default_index: int = 0,
*,
cancel_label: str = "Cancel",
searchable: bool = False,
) -> int | None:
"""Curses single-select menu. Returns selected index or None on cancel.
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the returned value is always
the original item index (or None for cancel).
"""
all_items = list(items) + [cancel_label]
cancel_idx = len(items)
def _draw_row(stdscr, y, i, is_cursor, max_x):
arrow = "→" if is_cursor else " "
_draw_plain_row(stdscr, y, f" {arrow} {all_items[i]}", max_x, is_cursor=is_cursor)
def _on_action(action, cursor):
if action == NAV_SELECT:
# Selecting the synthetic cancel row resolves to None, mirroring
# the old post-loop ``>= cancel_idx`` guard.
return None if cursor >= cancel_idx else cursor
if action in (NAV_CANCEL, NAV_INTERRUPT):
return None
return _KEEP # NAV_TOGGLE — no-op for this menu
return _run_curses_menu(
initial_cursor=min(default_index, len(all_items) - 1),
item_count=len(all_items),
draw_header=_simple_header(title, "ENTER confirm", "ESC/q cancel", searchable),
draw_row=_draw_row,
on_action=_on_action,
reserve_bottom=1,
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
cancel_value=None,
searchable=searchable,
search_labels=list(all_items) if searchable else None,
)
def _numbered_single_fallback(
title: str,
items: List[str],
cancel_idx: int,
) -> int | None:
"""Text-based numbered fallback for single-select."""
print(f"\n {title}\n")
for i, label in enumerate(items, 1):
print(f" {i}. {label}")
print()
idx = _read_numbered_choice(f" Choice [1-{len(items)}]: ")
return idx if isinstance(idx, int) and 0 <= idx < min(len(items), cancel_idx) else None
def _numbered_fallback(
title: str,
items: List[str],
selected: Set[int],
cancel_returns: Set[int],
status_fn: Optional[Callable[[Set[int]], str]] = None,
) -> Set[int]:
"""Text-based toggle fallback for terminals without curses."""
chosen = set(selected)
print(color(f"\n {title}", Colors.YELLOW))
print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM))
while True:
for i, label in enumerate(items):
marker = color("[✓]", Colors.GREEN) if i in chosen else "[ ]"
print(f" {marker} {i + 1:>2}. {label}")
status_text = status_fn(chosen) if status_fn else ""
if status_text:
print(color(f"\n {status_text}", Colors.DIM))
print()
idx = _read_numbered_choice(color(" Toggle # (or Enter to confirm): ", Colors.DIM))
if idx is _NAV_ABORT:
return cancel_returns
if idx is None:
return chosen
if 0 <= idx < len(items):
chosen.symmetric_difference_update({idx})
print()