fix tmux menus
This commit is contained in:
22
AGENTS.md
22
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
|
## Testing Changes
|
||||||
|
|
||||||
After making changes:
|
After making changes:
|
||||||
|
|||||||
@@ -358,46 +358,88 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
|||||||
# ─── Menu Helpers ─────────────────────────────────────────────────────────────
|
# ─── Menu Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
||||||
"""Single-select menu (arrow keys)."""
|
"""Single-select menu (arrow keys). Uses curses to avoid simple_term_menu
|
||||||
print(color(question, Colors.YELLOW))
|
rendering bugs in tmux, iTerm, and other non-standard terminals."""
|
||||||
|
|
||||||
|
# Curses-based single-select — works in tmux, iTerm, and standard terminals
|
||||||
try:
|
try:
|
||||||
from simple_term_menu import TerminalMenu
|
import curses
|
||||||
menu = TerminalMenu(
|
result_holder = [default]
|
||||||
[f" {c}" for c in choices],
|
|
||||||
cursor_index=default,
|
def _curses_menu(stdscr):
|
||||||
menu_cursor="→ ",
|
curses.curs_set(0)
|
||||||
menu_cursor_style=("fg_green", "bold"),
|
if curses.has_colors():
|
||||||
menu_highlight_style=("fg_green",),
|
curses.start_color()
|
||||||
cycle_cursor=True,
|
curses.use_default_colors()
|
||||||
clear_screen=False,
|
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||||
)
|
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||||
idx = menu.show()
|
cursor = default
|
||||||
if idx is None:
|
|
||||||
return default
|
while True:
|
||||||
print()
|
stdscr.clear()
|
||||||
return idx
|
max_y, max_x = stdscr.getmaxyx()
|
||||||
except (ImportError, NotImplementedError):
|
try:
|
||||||
for i, c in enumerate(choices):
|
stdscr.addnstr(0, 0, question, max_x - 1,
|
||||||
marker = "●" if i == default else "○"
|
curses.A_BOLD | (curses.color_pair(2) if curses.has_colors() else 0))
|
||||||
style = Colors.GREEN if i == default else ""
|
except curses.error:
|
||||||
print(color(f" {marker} {c}", style) if style else f" {marker} {c}")
|
pass
|
||||||
while True:
|
|
||||||
try:
|
for i, c in enumerate(choices):
|
||||||
val = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM))
|
y = i + 2
|
||||||
if not val:
|
if y >= max_y - 1:
|
||||||
return default
|
break
|
||||||
idx = int(val) - 1
|
arrow = "→" if i == cursor else " "
|
||||||
if 0 <= idx < len(choices):
|
line = f" {arrow} {c}"
|
||||||
return idx
|
attr = curses.A_NORMAL
|
||||||
except (ValueError, KeyboardInterrupt, EOFError):
|
if i == cursor:
|
||||||
print()
|
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
|
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]:
|
def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]:
|
||||||
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
"""Multi-select checklist of toolsets. Returns set of selected toolset keys."""
|
||||||
import platform as _platform
|
|
||||||
|
|
||||||
labels = []
|
labels = []
|
||||||
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
|
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
|
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.
|
# 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:
|
try:
|
||||||
import curses
|
import curses
|
||||||
selected = set(pre_selected_indices)
|
selected = set(pre_selected_indices)
|
||||||
|
|||||||
Reference in New Issue
Block a user