When interactive TUI commands are invoked non-interactively (e.g. via the agent's terminal() tool through a subprocess pipe), curses loops spin at 100% CPU and input() calls hang indefinitely. Defense in depth — two layers: 1. Source-level guard in curses_checklist() (curses_ui.py + checklist.py): Returns cancel_returns immediately when stdin is not a TTY. This catches ALL callers automatically, including future code. 2. Command-level guards with clear error messages: - hermes tools (interactive checklist, not list/disable/enable) - hermes setup (interactive wizard) - hermes model (provider/model picker) - hermes whatsapp (pairing setup) - hermes skills config (skill toggle) - hermes mcp configure (tool selection) - hermes uninstall (confirmation prompt) Non-interactive subcommands (hermes tools list, hermes tools enable, hermes mcp add/remove/list/test, hermes skills search/install/browse) remain unaffected.
173 lines
6.4 KiB
Python
173 lines
6.4 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 typing import Callable, List, Optional, 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,
|
|
status_fn: Optional[Callable[[Set[int]], str]] = 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*.
|
|
status_fn: Optional callback ``f(chosen_indices) -> str`` whose return
|
|
value is rendered on the bottom row of the terminal. Use this for
|
|
live aggregate info (e.g. estimated token counts).
|
|
"""
|
|
if cancel_returns is None:
|
|
cancel_returns = set(selected)
|
|
|
|
# Safety: curses and input() both hang or spin when stdin is not a
|
|
# terminal (e.g. subprocess pipe). Return defaults immediately.
|
|
if not sys.stdin.isatty():
|
|
return cancel_returns
|
|
|
|
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()
|
|
|
|
# Reserve bottom row for status bar when status_fn provided
|
|
footer_rows = 1 if status_fn else 0
|
|
|
|
# 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 - footer_rows
|
|
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 - footer_rows:
|
|
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
|
|
|
|
# Status bar (bottom row, right-aligned)
|
|
if status_fn:
|
|
try:
|
|
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
|
|
if curses.has_colors():
|
|
sattr |= curses.color_pair(3)
|
|
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
|
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, status_fn)
|
|
|
|
|
|
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}")
|
|
if status_fn:
|
|
status_text = status_fn(chosen)
|
|
if status_text:
|
|
print(color(f"\n {status_text}", Colors.DIM))
|
|
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
|