cleanup: remove references to simple-term-menu

we migrated away long ago.
clean up all docs references the dependency itself
This commit is contained in:
ethernet 2026-08-10 11:01:41 -04:00
parent 1362ffc7d2
commit 37e46c774c
10 changed files with 36 additions and 185 deletions

View File

@ -1260,12 +1260,8 @@ Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_her
for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
### DO NOT introduce new `simple_term_menu` usage
Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
the preferred UI is curses (stdlib) because `simple_term_menu` has
ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
interactive menus must use `hermes_cli/curses_ui.py` — see
`hermes_cli/tools_config.py` for the canonical pattern.
### All CLI menu-pickers MUST use curses.
Interactive menus must use `hermes_cli/curses_ui.py`. See `hermes_cli/tools_config.py` for an example.
### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.

View File

@ -723,22 +723,9 @@ that touches the OS, assume *any* platform can hit your code path.
For process enumeration: PowerShell's `Get-CimInstance Win32_Process` is
the modern replacement for `wmic process`. See
`hermes_cli/gateway.py::_scan_gateway_pids` for the pattern.
3. **`termios` and `fcntl` are Unix-only.** Always catch both `ImportError`
and `NotImplementedError`:
```python
try:
from simple_term_menu import TerminalMenu
menu = TerminalMenu(options)
idx = menu.show()
except (ImportError, NotImplementedError):
# Fallback: numbered menu for Windows
for i, opt in enumerate(options):
print(f" {i+1}. {opt}")
idx = int(input("Choice: ")) - 1
```
4. **File encoding.** Windows may save `.env` files in `cp1252`. Always
3. **File encoding.** Windows may save `.env` files in `cp1252`. Always
handle encoding errors:
```python
try:
@ -750,7 +737,7 @@ that touches the OS, assume *any* platform can hit your code path.
similar editors — use `encoding="utf-8-sig"` when reading files that
could have been touched by a Windows GUI editor.
5. **Process management.** `os.setsid()`, `os.killpg()`, `os.fork()`,
4. **Process management.** `os.setsid()`, `os.killpg()`, `os.fork()`,
`os.getuid()`, and POSIX signal handling differ on Windows. Guard with
`platform.system()`, `sys.platform`, or `hasattr(os, "setsid")`:
```python
@ -774,29 +761,29 @@ that touches the OS, assume *any* platform can hit your code path.
pass
```
6. **Signals that don't exist on Windows: `SIGALRM`, `SIGCHLD`, `SIGHUP`,
5. **Signals that don't exist on Windows: `SIGALRM`, `SIGCHLD`, `SIGHUP`,
`SIGUSR1`, `SIGUSR2`, `SIGPIPE`, `SIGQUIT`, `SIGKILL`.** Python's
`signal` module raises `AttributeError` at import time if you reference
them on Windows. Use `getattr(signal, "SIGKILL", signal.SIGTERM)` or
gate the whole block behind a platform check. `loop.add_signal_handler`
raises `NotImplementedError` on Windows — always catch it.
7. **Path separators.** Use `pathlib.Path` instead of string concatenation
6. **Path separators.** Use `pathlib.Path` instead of string concatenation
with `/`. Forward slashes work almost everywhere on Windows, but
`subprocess.run(["cmd.exe", "/c", ...])` and other shell contexts can
require backslashes — convert with `str(path)` at the subprocess boundary,
not inside Python logic.
8. **Symlinks need elevated privileges on Windows** (unless Developer Mode is
7. **Symlinks need elevated privileges on Windows** (unless Developer Mode is
on). Tests that create symlinks need `@pytest.mark.skipif(sys.platform ==
"win32", reason="Symlinks require elevated privileges on Windows")`.
9. **POSIX file modes (0o600, 0o644, etc.) are NOT enforced on NTFS** by
8. **POSIX file modes (0o600, 0o644, etc.) are NOT enforced on NTFS** by
default. Tests that assert on `stat().st_mode & 0o777` must skip on
Windows — the concept doesn't translate. Use ACLs (`icacls`, `pywin32`)
for Windows secret-file protection if needed.
10. **Detached background daemons on Windows need `pythonw.exe`, NOT
9. **Detached background daemons on Windows need `pythonw.exe`, NOT
`python.exe`.** `python.exe` always allocates or attaches to a console,
which makes it vulnerable to `CTRL_C_EVENT` broadcasts from any sibling
process. `pythonw.exe` is the no-console variant. Combine with
@ -805,38 +792,38 @@ that touches the OS, assume *any* platform can hit your code path.
See `hermes_cli/gateway_windows.py::_spawn_detached` for the reference
implementation.
11. **`subprocess.Popen` with `.cmd` or `.bat` shims needs `shutil.which`
10. **`subprocess.Popen` with `.cmd` or `.bat` shims needs `shutil.which`
to resolve.** Passing `"agent-browser"` to `Popen` on Windows finds
the extensionless POSIX shebang shim in `node_modules/.bin/`, which
`CreateProcessW` can't execute — you'll get `WinError 193 "not a valid
Win32 application"`. Use `shutil.which("agent-browser", path=local_bin)`
which honors PATHEXT and picks the `.CMD` variant on Windows.
12. **Don't use shell shebangs as a way to run Python.** `#!/usr/bin/env
11. **Don't use shell shebangs as a way to run Python.** `#!/usr/bin/env
python` only works when the file is executed through a Unix shell.
`subprocess.run(["./myscript.py"])` on Windows fails even if the file
has a shebang line. Always invoke Python explicitly:
`[sys.executable, "myscript.py"]`.
13. **Shell commands in installers.** If you change `scripts/install.sh`,
12. **Shell commands in installers.** If you change `scripts/install.sh`,
make the equivalent change in `scripts/install.ps1`. The two scripts
are the canonical example of "works on Linux does not mean works on
Windows" and have drifted multiple times — keep them in lockstep.
14. **Known paths that are OneDrive-redirected on Windows:** Desktop,
13. **Known paths that are OneDrive-redirected on Windows:** Desktop,
Documents, Pictures, Videos. The "real" path when OneDrive Backup is
enabled is `%USERPROFILE%\OneDrive\Desktop` (etc.), NOT
`%USERPROFILE%\Desktop` (which exists as an empty husk). Resolve the
real location via `ctypes` + `SHGetKnownFolderPath` or by reading the
`Shell Folders` registry key — never assume `~/Desktop`.
15. **CRLF vs LF in generated scripts.** Windows `cmd.exe` and `schtasks`
14. **CRLF vs LF in generated scripts.** Windows `cmd.exe` and `schtasks`
parse line-by-line; mixed or LF-only line endings can break multi-line
`.cmd` / `.bat` files. Use `open(path, "w", encoding="utf-8",
newline="\r\n")` — or `open(path, "wb")` + explicit bytes — when
generating scripts Windows will execute.
16. **Two different quoting schemes in one command line.** `subprocess.run
15. **Two different quoting schemes in one command line.** `subprocess.run
(["schtasks", "/TR", some_cmd])` → schtasks itself parses `/TR`, AND
the `some_cmd` string is re-parsed by `cmd.exe` when the task fires.
Different parsers, different escape rules. Use two separate quoting
@ -846,18 +833,15 @@ that touches the OS, assume *any* platform can hit your code path.
### Testing cross-platform
Tests that use POSIX-only syscalls need a skip marker. Common ones:
- Symlinks → `@pytest.mark.skipif(sys.platform == "win32", ...)`
- `0o600` file modes → `@pytest.mark.skipif(sys.platform.startswith("win"), ...)`
- `signal.SIGALRM` → Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim in `tests/conftest.py::pytest_configure`)
- `os.setsid` / `os.fork` → Unix-only
- Live Winsock / Windows-specific regression tests →
`@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")`
Tests that excercise behavior on specific platforms must run on their target platforms.
If you monkeypatch `sys.platform` for cross-platform tests, also patch
`platform.system()` / `platform.release()` / `platform.mac_ver()` — each
re-reads the real OS independently, so half-patched tests still route
through the wrong branch on a Windows runner.
```python
@pytest.mark.linux_only
@pytest.mark.macos_only
@pytest.mark.windows_only
```
Avoid monkeypatching `sys.platform` unless absolutely needed, but if you do, also patch `platform.system()` / `platform.release()` / `platform.mac_ver()`.
Symlinks, 0o600 permissions, SIGALRM, os.setsid/fork are all unix-only.
---

View File

@ -7626,7 +7626,7 @@ def _prompt_model_selection(
menu_title = "Select default model:"
if has_pricing:
# Align the header with the model column.
# Each choice is " {label}" (2 spaces) and simple_term_menu prepends
# Each choice is " {label}" (2 spaces) and we prepend
# a 3-char cursor region ("-> " or " "), so content starts at col 5.
pad = " " * 5
header = f"\n{pad}{'':>{name_col}} {'In':>{price_col}} {'Out':>{price_col}}"
@ -7639,10 +7639,6 @@ def _prompt_model_selection(
menu_title += " ★ = on sale"
# Try arrow-key menu first, fall back to number input.
# Uses the shared curses radiolist (ESC/arrow-key handling that works
# across terminals, incl. those that emit raw escape sequences) instead
# of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow
# keys unreliable in the setup model picker.
try:
from hermes_cli.curses_ui import curses_radiolist

View File

@ -340,11 +340,10 @@ def _handle_active_search_key(
def flush_stdin() -> None:
"""Flush any stray bytes from the stdin input buffer.
Must be called after ``curses.wrapper()`` (or any terminal-mode library
like simple_term_menu) returns, **before** the next ``input()`` /
``getpass.getpass()`` call. ``curses.endwin()`` restores the terminal
but does NOT drain the OS input buffer leftover escape-sequence bytes
(from arrow keys, terminal mode-switch responses, or rapid keypresses)
Must be called after ``curses.wrapper()`` returns, and before the next
``input()`` / ``getpass.getpass()`` call.
``curses.endwin()`` restores the terminal but does NOT drain the OS input buffer.
Leftover escape-sequence bytes (from arrow keys, terminal mode-switch responses, or rapid keypresses)
remain buffered and silently get consumed by the next ``input()`` call,
corrupting user data (e.g. writing ``^[^[`` into .env files).
@ -874,8 +873,7 @@ def curses_single_select(
) -> int | None:
"""Curses single-select menu. Returns selected index or None on cancel.
Works inside prompt_toolkit because curses.wrapper() restores the terminal
safely, unlike simple_term_menu which conflicts with /dev/tty.
Works inside prompt_toolkit. curses.wrapper() restores the terminal safely.
When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the
returned value is always the original item index (or None for cancel).

View File

@ -1082,8 +1082,6 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
"""Interactive curses-based session browser with live search filtering.
Returns the selected session ID, or None if cancelled.
Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering
bug in tmux/iTerm when arrow keys are used.
"""
if not sessions:
print("No sessions found.")

View File

@ -192,7 +192,6 @@ matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0",
# and XXE. aiohttp/httpx are already in [messaging]; defusedxml lands
# here to keep the dependency local to wecom_callback's threat model.
wecom = ["defusedxml==0.7.1"]
cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"]
voice = [
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime).
@ -280,7 +279,6 @@ termux = [
# Baseline Android / Termux path for reliable fresh installs.
"python-telegram-bot[webhooks]==22.6",
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[mcp]",
"hermes-agent[honcho]",
"hermes-agent[acp]",
@ -349,7 +347,6 @@ all = [
# and failed on `make`. Lazy-install routes that build to first use,
# where the user is expected to have a toolchain available.
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[pty]",
"hermes-agent[mcp]",
"hermes-agent[homeassistant]",

View File

@ -1,67 +0,0 @@
"""Regression tests confirming the setup model/provider/reasoning pickers route
through the shared curses radiolist (ESC + arrow-key handling that works across
terminals, incl. Ghostty) instead of simple_term_menu.
Guards against silently regressing back to simple_term_menu, whose ESC/arrow
handling was unreliable in `hermes setup` (the provider->model sub-menu).
"""
from unittest.mock import patch
def test_prompt_model_selection_uses_curses_radiolist():
from hermes_cli.auth import _prompt_model_selection
from hermes_cli.curses_ui import radio_item_plain
seen = {}
def _fake(
title,
items,
*,
selected=0,
cancel_returns=None,
description=None,
searchable=False,
search_labels=None,
):
seen["title"] = title
seen["items"] = items
seen["search_labels"] = search_labels
return 1 # pick second model
with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \
patch("builtins.print"):
result = _prompt_model_selection(["model-a", "model-b"])
assert result == "model-b"
assert seen["title"] == "Select default model:"
# Items are the models plus the custom/skip entries. Model rows may be
# rich (text, style) segments for sale chrome — compare the plain text.
plain = [radio_item_plain(item) for item in seen["items"]]
assert plain[:2] == ["model-a", "model-b"]
assert "Skip (keep current)" in plain
assert seen["search_labels"] is not None
assert len(seen["search_labels"]) == len(seen["items"])
def test_prompt_model_selection_esc_cancels():
from hermes_cli.auth import _prompt_model_selection
# curses_radiolist returns the cancel sentinel (-1) on ESC.
with patch("hermes_cli.curses_ui.curses_radiolist", return_value=-1), \
patch("builtins.print"):
result = _prompt_model_selection(["model-a", "model-b"])
assert result is None
def test_reasoning_effort_uses_curses_radiolist():
from hermes_cli.main import _prompt_reasoning_effort_selection
with patch("hermes_cli.curses_ui.curses_radiolist", return_value=2), \
patch("builtins.print"):
result = _prompt_reasoning_effort_selection(["low", "medium", "high"], current_effort="")
assert result == "high"

20
uv.lock
View File

@ -1616,7 +1616,6 @@ all = [
{ name = "mcp" },
{ name = "pyasn1" },
{ name = "python-multipart" },
{ name = "simple-term-menu" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "youtube-transcript-api" },
@ -1630,9 +1629,6 @@ azure-identity = [
bedrock = [
{ name = "boto3" },
]
cli = [
{ name = "simple-term-menu" },
]
computer-use = [
{ name = "mcp" },
{ name = "starlette" },
@ -1744,7 +1740,6 @@ termux = [
{ name = "honcho-ai" },
{ name = "mcp" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "simple-term-menu" },
{ name = "starlette" },
]
termux-all = [
@ -1761,7 +1756,6 @@ termux-all = [
{ name = "pyasn1" },
{ name = "python-multipart" },
{ name = "python-telegram-bot", extra = ["webhooks"] },
{ name = "simple-term-menu" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
@ -1845,8 +1839,6 @@ requires-dist = [
{ name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" },
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" },
@ -1918,7 +1910,6 @@ requires-dist = [
{ name = "sentencepiece", marker = "extra == 'wake'", specifier = "==0.2.2" },
{ name = "setuptools", marker = "extra == 'dev'", specifier = "==83.0.0" },
{ name = "sherpa-onnx", marker = "extra == 'wake'", specifier = "==1.13.4" },
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.29.0" },
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.29.0" },
{ name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.43.0" },
@ -1940,7 +1931,7 @@ requires-dist = [
{ name = "websockets", specifier = "==15.0.1" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "tts-premium", "voice", "wake", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "otlp", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"
@ -4169,15 +4160,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/40/ee8a0a8c83fc6d7f5245a5a031e471d3b115e20cce867e7abb2f9d4185c9/sherpa_onnx-1.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:17050fdfb48d37ae996364f697c554a1399740d18e5a56b143c011d00cfed3e0", size = 2244504, upload-time = "2026-07-07T12:32:59.882Z" },
]
[[package]]
name = "simple-term-menu"
version = "1.6.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/80/f0f10b4045628645a841d3d98b584a8699005ee03a211fc7c45f6c6f0e99/simple_term_menu-1.6.6.tar.gz", hash = "sha256:9813d36f5749d62d200a5599b1ec88469c71378312adc084c00c00bfbb383893", size = 35493, upload-time = "2024-12-02T16:31:50.639Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/09/21d993e394c1fe5c44cd90453d88ed44932da8dfca006e424c072d77d29b/simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2", size = 27600, upload-time = "2024-12-02T16:31:48.934Z" },
]
[[package]]
name = "six"
version = "1.17.0"

View File

@ -159,23 +159,7 @@ When contributing code, keep these rules in mind:
Key patterns:
### 1. `termios` and `fcntl` are Unix-only
Always catch both `ImportError` and `NotImplementedError`:
```python
try:
from simple_term_menu import TerminalMenu
menu = TerminalMenu(options)
idx = menu.show()
except (ImportError, NotImplementedError):
# Fallback: numbered menu
for i, opt in enumerate(options):
print(f" {i+1}. {opt}")
idx = int(input("Choice: ")) - 1
```
### 2. File encoding
### 1. File encoding
Some environments may save `.env` files in non-UTF-8 encodings:
@ -186,7 +170,7 @@ except UnicodeDecodeError:
load_dotenv(env_path, encoding="latin-1")
```
### 3. Process management
### 2. Process management
`os.setsid()`, `os.killpg()`, and signal handling differ across platforms:
@ -196,7 +180,7 @@ if platform.system() != "Windows":
kwargs["preexec_fn"] = os.setsid
```
### 4. Path separators
### 3. Path separators
Use `pathlib.Path` instead of string concatenation with `/`.

View File

@ -132,24 +132,7 @@ Hermes 官方支持 **Linux、macOS、WSL2 以及原生 Windows通过 PowerSh
- **使用 `pathlib.Path` / `os.path.join`,不得手动用 `/` 拼接路径。** 这对我们构造后传给子进程的字符串尤为重要,而非 OS 返回给我们的字符串。
关键模式:
### 1. `termios``fcntl` 仅适用于 Unix
始终同时捕获 `ImportError``NotImplementedError`
```python
try:
from simple_term_menu import TerminalMenu
menu = TerminalMenu(options)
idx = menu.show()
except (ImportError, NotImplementedError):
# 回退:编号菜单
for i, opt in enumerate(options):
print(f" {i+1}. {opt}")
idx = int(input("Choice: ")) - 1
```
### 2. 文件编码
### 1. 文件编码
某些环境可能以非 UTF-8 编码保存 `.env` 文件:
@ -160,7 +143,7 @@ except UnicodeDecodeError:
load_dotenv(env_path, encoding="latin-1")
```
### 3. 进程管理
### 2. 进程管理
`os.setsid()`、`os.killpg()` 以及信号处理在各平台间存在差异:
@ -170,7 +153,7 @@ if platform.system() != "Windows":
kwargs["preexec_fn"] = os.setsid
```
### 4. 路径分隔符
### 3. 路径分隔符
使用 `pathlib.Path` 代替用 `/` 进行字符串拼接。