diff --git a/AGENTS.md b/AGENTS.md index 5001e1d7..cc66a5c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -679,6 +679,28 @@ Key files: --- +## Known Pitfalls + +### DO NOT use `simple_term_menu` for interactive menus + +`simple_term_menu` has rendering bugs in tmux, iTerm2, and other non-standard terminals. When the user scrolls with arrow keys, previously highlighted items "ghost" — duplicating upward and corrupting the display. This happens because the library uses ANSI cursor-up codes to redraw in place, and tmux/iTerm miscalculate positions when the menu is near the bottom of the viewport. + +**Rule:** All interactive menus in `hermes_cli/` must use `curses` (Python stdlib) instead. See `tools_config.py` for the pattern — both `_prompt_choice()` (single-select) and `_prompt_toolset_checklist()` (multi-select with space toggle) use `curses.wrapper()`. The numbered-input fallback handles Windows where curses isn't available. + +### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code + +The ANSI escape `\033[K` leaks as literal `?[K` text when `prompt_toolkit`'s `patch_stdout` is active. Use space-padding instead to clear lines: `f"\r{line}{' ' * pad}"`. See `agent/display.py` `KawaiiSpinner`. + +### `_last_resolved_tool_names` is a process-global in `model_tools.py` + +The `execute_code` sandbox uses `_last_resolved_tool_names` (set by `get_tool_definitions()`) to decide which tool stubs to generate. When subagents run with restricted toolsets, they overwrite this global. After delegation returns to the parent, `execute_code` may see the child's restricted list instead of the parent's full list. This is a known bug — `execute_code` calls after delegation may fail with `ImportError: cannot import name 'patch' from 'hermes_tools'`. + +### Tests must not write to `~/.hermes/` + +The `autouse` fixture `_isolate_hermes_home` in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Every test runs in isolation. If you add a test that creates `AIAgent` instances or writes session logs, the fixture handles cleanup automatically. Never hardcode `~/.hermes/` paths in tests. + +--- + ## Testing Changes After making changes: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9773c697..ef8daa8b 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -358,46 +358,88 @@ def _toolset_has_keys(ts_key: str) -> bool: # ─── Menu Helpers ───────────────────────────────────────────────────────────── def _prompt_choice(question: str, choices: list, default: int = 0) -> int: - """Single-select menu (arrow keys).""" - print(color(question, Colors.YELLOW)) + """Single-select menu (arrow keys). Uses curses to avoid simple_term_menu + rendering bugs in tmux, iTerm, and other non-standard terminals.""" + # Curses-based single-select — works in tmux, iTerm, and standard terminals try: - from simple_term_menu import TerminalMenu - menu = TerminalMenu( - [f" {c}" for c in choices], - cursor_index=default, - menu_cursor="→ ", - menu_cursor_style=("fg_green", "bold"), - menu_highlight_style=("fg_green",), - cycle_cursor=True, - clear_screen=False, - ) - idx = menu.show() - if idx is None: - return default - print() - return idx - except (ImportError, NotImplementedError): - for i, c in enumerate(choices): - marker = "●" if i == default else "○" - style = Colors.GREEN if i == default else "" - print(color(f" {marker} {c}", style) if style else f" {marker} {c}") - while True: - try: - val = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) - if not val: - return default - idx = int(val) - 1 - if 0 <= idx < len(choices): - return idx - except (ValueError, KeyboardInterrupt, EOFError): - print() + import curses + result_holder = [default] + + def _curses_menu(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) + cursor = default + + while True: + stdscr.clear() + max_y, max_x = stdscr.getmaxyx() + try: + stdscr.addnstr(0, 0, question, max_x - 1, + curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0)) + except curses.error: + pass + + for i, c in enumerate(choices): + y = i + 2 + if y >= max_y - 1: + break + arrow = "→" if i == cursor else " " + line = f" {arrow} {c}" + 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(choices) + elif key in (curses.KEY_DOWN, ord('j')): + cursor = (cursor + 1) % len(choices) + elif key in (curses.KEY_ENTER, 10, 13): + result_holder[0] = cursor + return + elif key in (27, ord('q')): + return + + curses.wrapper(_curses_menu) + return result_holder[0] + + except Exception: + pass + + # Fallback: numbered input (Windows without curses, etc.) + print(color(question, Colors.YELLOW)) + for i, c in enumerate(choices): + marker = "●" if i == default else "○" + style = Colors.GREEN if i == default else "" + print(color(f" {marker} {i+1}. {c}", style) if style else f" {marker} {i+1}. {c}") + while True: + try: + val = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) + if not val: return default + idx = int(val) - 1 + if 0 <= idx < len(choices): + return idx + except (ValueError, KeyboardInterrupt, EOFError): + print() + return default def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]: """Multi-select checklist of toolsets. Returns set of selected toolset keys.""" - import platform as _platform labels = [] for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS: @@ -411,48 +453,8 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str if ts_key in enabled ] - # simple_term_menu multi-select has rendering bugs on macOS terminals, - # so we use a curses-based fallback there. - use_term_menu = _platform.system() != "Darwin" - - if use_term_menu: - try: - from simple_term_menu import TerminalMenu - - print(color(f"Tools for {platform_label}", Colors.YELLOW)) - print(color(" SPACE to toggle, ENTER to confirm.", Colors.DIM)) - print() - - menu_items = [f" {label}" for label in labels] - menu = TerminalMenu( - menu_items, - multi_select=True, - show_multi_select_hint=False, - multi_select_cursor="[✓] ", - multi_select_select_on_accept=False, - multi_select_empty_ok=True, - preselected_entries=pre_selected_indices if pre_selected_indices else None, - menu_cursor="→ ", - menu_cursor_style=("fg_green", "bold"), - menu_highlight_style=("fg_green",), - cycle_cursor=True, - clear_screen=False, - clear_menu_on_exit=False, - ) - - menu.show() - - if menu.chosen_menu_entries is None: - return enabled - - selected_indices = list(menu.chosen_menu_indices or []) - return {CONFIGURABLE_TOOLSETS[i][0] for i in selected_indices} - - except (ImportError, NotImplementedError): - pass # fall through to curses/numbered fallback - # Curses-based multi-select — arrow keys + space to toggle + enter to confirm. - # Used on macOS (where simple_term_menu ghosts) and as a fallback. + # simple_term_menu has rendering bugs in tmux, iTerm, and other terminals. try: import curses selected = set(pre_selected_indices)