| """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) |
| cursor = 0 |
| scroll_offset = 0 |
|
|
| while True: |
| stdscr.clear() |
| max_y, max_x = stdscr.getmaxyx() |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|