"""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. """ from typing import List, Set from hermes_cli.colors import Colors, color def curses_checklist( title: str, items: List[str], selected: Set[int], *, cancel_returns: Set[int] | None = None, ) -> Set[int]: """Curses multi-select checklist. Returns set of selected indices. Args: title: Header line displayed above the checklist. items: Display labels for each row. selected: Indices that start checked (pre-selected). cancel_returns: Returned on ESC/q. Defaults to the original *selected*. """ if cancel_returns is None: cancel_returns = set(selected) try: import curses chosen = set(selected) result_holder: list = [None] 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) curses.init_pair(3, 8, -1) # dim gray cursor = 0 scroll_offset = 0 while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() # Header try: hattr = curses.A_BOLD if curses.has_colors(): hattr |= curses.color_pair(2) stdscr.addnstr(0, 0, title, max_x - 1, hattr) stdscr.addnstr( 1, 0, " ↑↓ navigate SPACE toggle ENTER confirm ESC cancel", max_x - 1, curses.A_DIM, ) except curses.error: pass # Scrollable item list visible_rows = max_y - 3 if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: scroll_offset = cursor - visible_rows + 1 for draw_i, i in enumerate( range(scroll_offset, min(len(items), scroll_offset + visible_rows)) ): y = draw_i + 3 if y >= max_y - 1: break check = "✓" if i in chosen else " " arrow = "→" if i == cursor else " " line = f" {arrow} [{check}] {items[i]}" attr = curses.A_NORMAL if i == cursor: attr = curses.A_BOLD if curses.has_colors(): attr |= curses.color_pair(1) try: stdscr.addnstr(y, 0, line, max_x - 1, attr) except curses.error: pass stdscr.refresh() key = stdscr.getch() if key in (curses.KEY_UP, ord("k")): cursor = (cursor - 1) % len(items) elif key in (curses.KEY_DOWN, ord("j")): cursor = (cursor + 1) % len(items) elif key == ord(" "): chosen.symmetric_difference_update({cursor}) elif key in (curses.KEY_ENTER, 10, 13): result_holder[0] = set(chosen) return elif key in (27, ord("q")): result_holder[0] = cancel_returns return curses.wrapper(_draw) return result_holder[0] if result_holder[0] is not None else cancel_returns except Exception: return _numbered_fallback(title, items, selected, cancel_returns) def _numbered_fallback( title: str, items: List[str], selected: Set[int], cancel_returns: Set[int], ) -> 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}") print() try: val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() if not val: break idx = int(val) - 1 if 0 <= idx < len(items): chosen.symmetric_difference_update({idx}) except (ValueError, KeyboardInterrupt, EOFError): return cancel_returns print() return chosen