mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 18:57:21 +08:00
Compare commits
17 Commits
bb/tui-cop
...
gemini-cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9845650fd6 | ||
|
|
98f5be13fa | ||
|
|
5e6e8b6af3 | ||
|
|
d9bf093728 | ||
|
|
faa467ccaf | ||
|
|
f45434d3c6 | ||
|
|
2a9a5fffa5 | ||
|
|
c2cb6d1071 | ||
|
|
b52b63396c | ||
|
|
528e7dc176 | ||
|
|
4899bd99c0 | ||
|
|
8652d47eaa | ||
|
|
7d96a5ab6e | ||
|
|
d3ab2b2e13 | ||
|
|
f7abcb4f01 | ||
|
|
10fcd620d2 | ||
|
|
d8afafd22b |
28
cli.py
28
cli.py
@@ -2119,6 +2119,11 @@ class HermesCLI:
|
||||
self._pending_input = queue.Queue()
|
||||
self._interrupt_queue = queue.Queue()
|
||||
self._should_exit = False
|
||||
# /exit --delete: when True, the current session's SQLite history and
|
||||
# on-disk transcripts are deleted during shutdown. Set by
|
||||
# process_command() when the user runs /exit --delete or /quit --delete.
|
||||
# Ported from google-gemini/gemini-cli#19332.
|
||||
self._delete_session_on_exit = False
|
||||
self._last_ctrl_c_time = 0
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
@@ -6083,6 +6088,16 @@ class HermesCLI:
|
||||
canonical = _cmd_def.name if _cmd_def else _base_word
|
||||
|
||||
if canonical in ("quit", "exit", "q"):
|
||||
# Parse --delete flag: /exit --delete also removes the current
|
||||
# session's transcripts + SQLite history. Ported from
|
||||
# google-gemini/gemini-cli#19332.
|
||||
_rest = cmd_original.split(None, 1)
|
||||
_args = (_rest[1] if len(_rest) > 1 else "").strip().lower()
|
||||
if _args in ("--delete", "-d"):
|
||||
self._delete_session_on_exit = True
|
||||
elif _args:
|
||||
_cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}")
|
||||
return True
|
||||
return False
|
||||
elif canonical == "help":
|
||||
self.show_help()
|
||||
@@ -11174,6 +11189,19 @@ class HermesCLI:
|
||||
self._session_db.end_session(self.agent.session_id, "cli_close")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not close session in DB: %s", e)
|
||||
# /exit --delete: also remove the current session's transcripts
|
||||
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
|
||||
if getattr(self, '_delete_session_on_exit', False):
|
||||
try:
|
||||
from hermes_constants import get_hermes_home as _ghh
|
||||
_sessions_dir = _ghh() / "sessions"
|
||||
_sid = self.agent.session_id
|
||||
if self._session_db.delete_session(_sid, sessions_dir=_sessions_dir):
|
||||
_cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}")
|
||||
else:
|
||||
_cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not delete session on exit: %s", e)
|
||||
# Plugin hook: on_session_end — safety net for interrupted exits.
|
||||
# run_conversation() already fires this per-turn on normal completion,
|
||||
# so only fire here if the agent was mid-turn (_agent_running) when
|
||||
|
||||
@@ -183,8 +183,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
|
||||
|
||||
# Exit
|
||||
CommandDef("quit", "Exit the CLI", "Exit",
|
||||
cli_only=True, aliases=("exit",)),
|
||||
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",
|
||||
cli_only=True, aliases=("exit",), args_hint="[--delete]"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1094,11 +1094,36 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
return [node, str(root / "dist" / "entry.js")], root
|
||||
|
||||
|
||||
def _normalize_tui_toolsets(toolsets: object) -> list[str]:
|
||||
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
|
||||
try:
|
||||
from hermes_cli.oneshot import _normalize_toolsets
|
||||
|
||||
return _normalize_toolsets(toolsets) or []
|
||||
except (AttributeError, ImportError):
|
||||
if not toolsets:
|
||||
return []
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item]
|
||||
|
||||
|
||||
def _launch_tui(
|
||||
resume_session_id: Optional[str] = None,
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
):
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
@@ -1123,6 +1148,9 @@ def _launch_tui(
|
||||
if provider:
|
||||
env["HERMES_TUI_PROVIDER"] = provider
|
||||
env["HERMES_INFERENCE_PROVIDER"] = provider
|
||||
tui_toolsets = _normalize_tui_toolsets(toolsets)
|
||||
if tui_toolsets:
|
||||
env["HERMES_TUI_TOOLSETS"] = ",".join(tui_toolsets)
|
||||
# Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is
|
||||
# ~1.5–4GB depending on version and can fatal-OOM on long sessions with
|
||||
# large transcripts / reasoning blobs. Token-level merge: respect any
|
||||
@@ -1270,6 +1298,7 @@ def cmd_chat(args):
|
||||
tui_dev=getattr(args, "tui_dev", False),
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
)
|
||||
|
||||
# Import and run the CLI
|
||||
@@ -7887,6 +7916,12 @@ For more help on a command:
|
||||
"Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
default=None,
|
||||
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
"-r",
|
||||
@@ -10327,6 +10362,7 @@ Examples:
|
||||
args.oneshot,
|
||||
model=getattr(args, "model", None),
|
||||
provider=getattr(args, "provider", None),
|
||||
toolsets=getattr(args, "toolsets", None),
|
||||
))
|
||||
|
||||
# Handle top-level --resume / --continue as shortcut to chat
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
Bypasses cli.py entirely. No banner, no spinner, no session_id line,
|
||||
no stderr chatter. Just the agent's final text to stdout.
|
||||
|
||||
Toolsets = whatever the user has configured for "cli" in `hermes tools`.
|
||||
Toolsets = explicit --toolsets when provided, otherwise whatever the user has
|
||||
configured for "cli" in `hermes tools`.
|
||||
Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn.
|
||||
Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call).
|
||||
Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual).
|
||||
@@ -28,10 +29,103 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
|
||||
if not toolsets:
|
||||
return None
|
||||
|
||||
raw_items = [toolsets] if isinstance(toolsets, str) else toolsets
|
||||
if not isinstance(raw_items, (list, tuple)):
|
||||
raw_items = [raw_items]
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
if isinstance(item, str):
|
||||
normalized.extend(part.strip() for part in item.split(","))
|
||||
else:
|
||||
normalized.append(str(item).strip())
|
||||
|
||||
return [item for item in normalized if item] or None
|
||||
|
||||
|
||||
def _validate_explicit_toolsets(toolsets: object = None) -> tuple[list[str] | None, str | None]:
|
||||
normalized = _normalize_toolsets(toolsets)
|
||||
if normalized is None:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
from toolsets import validate_toolset
|
||||
except Exception as exc:
|
||||
return None, f"hermes -z: failed to validate --toolsets: {exc}\n"
|
||||
|
||||
built_in = [name for name in normalized if validate_toolset(name)]
|
||||
unresolved = [name for name in normalized if name not in built_in]
|
||||
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
plugin_valid = [name for name in unresolved if validate_toolset(name)]
|
||||
except Exception:
|
||||
plugin_valid = []
|
||||
|
||||
if plugin_valid:
|
||||
built_in.extend(plugin_valid)
|
||||
unresolved = [name for name in unresolved if name not in plugin_valid]
|
||||
|
||||
if any(name in {"all", "*"} for name in built_in):
|
||||
ignored = [name for name in normalized if name not in {"all", "*"}]
|
||||
if ignored:
|
||||
sys.stderr.write(
|
||||
"hermes -z: --toolsets all enables every toolset; "
|
||||
f"ignoring additional entries: {', '.join(ignored)}\n"
|
||||
)
|
||||
return None, None
|
||||
|
||||
mcp_names: set[str] = set()
|
||||
mcp_disabled: set[str] = set()
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
cfg = read_raw_config()
|
||||
mcp_servers = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
|
||||
for name, server_cfg in mcp_servers.items():
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
if _parse_enabled_flag(server_cfg.get("enabled", True), default=True):
|
||||
mcp_names.add(str(name))
|
||||
else:
|
||||
mcp_disabled.add(str(name))
|
||||
except Exception:
|
||||
mcp_names = set()
|
||||
mcp_disabled = set()
|
||||
|
||||
mcp_valid = [name for name in unresolved if name in mcp_names]
|
||||
disabled = [name for name in unresolved if name in mcp_disabled]
|
||||
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
|
||||
valid = built_in + mcp_valid
|
||||
|
||||
if unknown:
|
||||
sys.stderr.write(f"hermes -z: ignoring unknown --toolsets entries: {', '.join(unknown)}\n")
|
||||
if disabled:
|
||||
sys.stderr.write(
|
||||
"hermes -z: ignoring disabled MCP servers (set enabled: true in config.yaml to use): "
|
||||
f"{', '.join(disabled)}\n"
|
||||
)
|
||||
|
||||
if not valid:
|
||||
return None, "hermes -z: --toolsets did not contain any valid toolsets.\n"
|
||||
|
||||
return valid, None
|
||||
|
||||
|
||||
def run_oneshot(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
) -> int:
|
||||
"""Execute a single prompt and print only the final content block.
|
||||
|
||||
@@ -42,6 +136,7 @@ def run_oneshot(
|
||||
provider: Optional provider override. Falls back to
|
||||
HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider,
|
||||
then "auto".
|
||||
toolsets: Optional comma-separated string or iterable of toolsets.
|
||||
|
||||
Returns the exit code. Caller should sys.exit() with the return.
|
||||
"""
|
||||
@@ -65,6 +160,12 @@ def run_oneshot(
|
||||
)
|
||||
return 2
|
||||
|
||||
explicit_toolsets, toolsets_error = _validate_explicit_toolsets(toolsets)
|
||||
if toolsets_error:
|
||||
sys.stderr.write(toolsets_error)
|
||||
return 2
|
||||
use_config_toolsets = _normalize_toolsets(toolsets) is None
|
||||
|
||||
# Auto-approve any shell / tool approvals. Non-interactive by
|
||||
# definition — a prompt would hang forever.
|
||||
os.environ["HERMES_YOLO_MODE"] = "1"
|
||||
@@ -77,7 +178,13 @@ def run_oneshot(
|
||||
|
||||
try:
|
||||
with redirect_stdout(devnull), redirect_stderr(devnull):
|
||||
response = _run_agent(prompt, model=model, provider=provider)
|
||||
response = _run_agent(
|
||||
prompt,
|
||||
model=model,
|
||||
provider=provider,
|
||||
toolsets=explicit_toolsets,
|
||||
use_config_toolsets=use_config_toolsets,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
devnull.close()
|
||||
@@ -96,6 +203,8 @@ def _run_agent(
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
use_config_toolsets: bool = True,
|
||||
) -> str:
|
||||
"""Build an AIAgent exactly like a normal CLI chat turn would, then
|
||||
run a single conversation. Returns the final response string."""
|
||||
@@ -168,9 +277,12 @@ def _run_agent(
|
||||
explicit_base_url=explicit_base_url_from_alias,
|
||||
)
|
||||
|
||||
# Pull in whatever toolsets the user has enabled for "cli".
|
||||
# sorted() gives stable ordering; set→list for AIAgent's signature.
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
# Pull in explicit toolsets when provided; otherwise use whatever the user
|
||||
# has enabled for "cli". sorted() gives stable ordering for config-derived
|
||||
# sets; explicit values preserve user order.
|
||||
toolsets_list = _normalize_toolsets(toolsets)
|
||||
if toolsets_list is None and use_config_toolsets:
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
|
||||
agent = AIAgent(
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
@@ -71,6 +71,29 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
|
||||
def _clone_all_copytree_ignore(source_dir: Path):
|
||||
"""Ignore ``profiles/`` at the root of *source_dir* only.
|
||||
|
||||
``~/.hermes`` contains ``profiles/<name>/`` for sibling named profiles.
|
||||
``shutil.copytree`` would otherwise duplicate that entire tree inside the
|
||||
new profile (recursive ``.../profiles/.../profiles/...``). Export already
|
||||
excludes ``profiles`` via ``_DEFAULT_EXPORT_EXCLUDE_ROOT`` — match that
|
||||
behavior for ``--clone-all``.
|
||||
"""
|
||||
source_resolved = source_dir.resolve()
|
||||
|
||||
def _ignore(directory: str, names: List[str]) -> List[str]:
|
||||
try:
|
||||
if Path(directory).resolve() == source_resolved:
|
||||
return [n for n in names if n == "profiles"]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return []
|
||||
|
||||
return _ignore
|
||||
|
||||
|
||||
# Directories/files to exclude when exporting the default (~/.hermes) profile.
|
||||
# The default profile contains infrastructure (repo checkout, worktrees, DBs,
|
||||
# caches, binaries) that named profiles don't have. We exclude those so the
|
||||
@@ -424,8 +447,12 @@ def create_profile(
|
||||
)
|
||||
|
||||
if clone_all and source_dir:
|
||||
# Full copy of source profile
|
||||
shutil.copytree(source_dir, profile_dir)
|
||||
# Full copy of source profile (exclude sibling ~/.hermes/profiles/)
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
profile_dir,
|
||||
ignore=_clone_all_copytree_ignore(source_dir),
|
||||
)
|
||||
# Strip runtime files
|
||||
for stale in _CLONE_ALL_STRIP:
|
||||
(profile_dir / stale).unlink(missing_ok=True)
|
||||
|
||||
@@ -63,6 +63,7 @@ AUTHOR_MAP = {
|
||||
"162235745+0z1-ghb@users.noreply.github.com": "0z1-ghb",
|
||||
"yes999zc@163.com": "yes999zc",
|
||||
"343873859@qq.com": "DrStrangerUJN",
|
||||
"252818347@qq.com": "hejuntt1014",
|
||||
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
||||
"beliefanx@gmail.com": "BeliefanX",
|
||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||
|
||||
119
tests/cli/test_exit_delete_session.py
Normal file
119
tests/cli/test_exit_delete_session.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for `/exit --delete` and `/quit --delete` session deletion.
|
||||
|
||||
Ports the behavior from google-gemini/gemini-cli#19332: running `/exit` or
|
||||
`/quit` with the `--delete` flag arms a one-shot `_delete_session_on_exit`
|
||||
flag that the CLI shutdown path uses to remove the current session from
|
||||
SQLite + on-disk transcripts before exit.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_cli():
|
||||
"""Bare HermesCLI suitable for process_command() tests.
|
||||
|
||||
Uses ``__new__`` to skip the heavy __init__; only sets the attributes
|
||||
the /exit branch touches.
|
||||
"""
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli.config = {}
|
||||
cli.console = MagicMock()
|
||||
cli.agent = None
|
||||
cli.conversation_history = []
|
||||
cli.session_id = "test-session"
|
||||
cli._delete_session_on_exit = False
|
||||
return cli
|
||||
|
||||
|
||||
class TestExitDeleteFlag:
|
||||
def test_plain_exit_does_not_arm_delete(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_plain_quit_does_not_arm_delete(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/quit")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_exit_delete_arms_flag(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delete")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_quit_delete_arms_flag(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/quit --delete")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_exit_delete_short_form(self):
|
||||
"""`-d` is a convenience alias for `--delete`."""
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit -d")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_quit_alias_q_is_not_quit(self):
|
||||
"""`/q` is the alias for `/queue`, not `/quit`. This test documents
|
||||
that /q --delete does NOT arm session deletion — it would dispatch
|
||||
to /queue instead."""
|
||||
cli = _make_cli()
|
||||
cli._pending_input = __import__("queue").Queue()
|
||||
# /q with no args shows a usage error and keeps the CLI running.
|
||||
result = cli.process_command("/q")
|
||||
assert result is not False # queue command doesn't exit
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_delete_flag_is_case_insensitive(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --DELETE")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_delete_flag_trims_whitespace(self):
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delete ")
|
||||
assert result is False
|
||||
assert cli._delete_session_on_exit is True
|
||||
|
||||
def test_unknown_exit_argument_does_not_exit(self):
|
||||
"""Unrecognised args should NOT exit the CLI — they surface an
|
||||
error message and stay in the session. This prevents accidental
|
||||
session destruction from typos like `/exit -delete`."""
|
||||
cli = _make_cli()
|
||||
result = cli.process_command("/exit --delte")
|
||||
# process_command returns True = keep running
|
||||
assert result is True
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
def test_unknown_exit_argument_prints_help(self):
|
||||
cli = _make_cli()
|
||||
# _cprint goes through module-level print, so capture via console.
|
||||
# We can't patch _cprint directly without import juggling; the
|
||||
# previous assertion already proves the unknown-arg branch is
|
||||
# reached (result True + flag False).
|
||||
result = cli.process_command("/exit garbage")
|
||||
assert result is True
|
||||
assert cli._delete_session_on_exit is False
|
||||
|
||||
|
||||
class TestCommandRegistry:
|
||||
def test_quit_command_advertises_delete_flag(self):
|
||||
"""The CommandDef args_hint should surface `--delete` in /help and
|
||||
CLI autocomplete."""
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("quit")
|
||||
assert cmd is not None
|
||||
assert cmd.args_hint == "[--delete]"
|
||||
|
||||
def test_exit_alias_resolves_to_quit_with_hint(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("exit")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "quit"
|
||||
assert cmd.args_hint == "[--delete]"
|
||||
@@ -171,6 +171,23 @@ class TestCreateProfile:
|
||||
assert not (profile_dir / "gateway_state.json").exists()
|
||||
assert not (profile_dir / "processes.json").exists()
|
||||
|
||||
def test_clone_all_excludes_sibling_profiles_tree(self, profile_env):
|
||||
"""--clone-all from default ~/.hermes must not copy profiles/* (nested explosion)."""
|
||||
tmp_path = profile_env
|
||||
default_home = tmp_path / ".hermes"
|
||||
profiles_root = default_home / "profiles"
|
||||
profiles_root.mkdir(exist_ok=True)
|
||||
(profiles_root / "other").mkdir(parents=True, exist_ok=True)
|
||||
(profiles_root / "other" / "marker.txt").write_text("sibling data")
|
||||
|
||||
(default_home / "memories").mkdir(exist_ok=True)
|
||||
(default_home / "memories" / "note.md").write_text("remember this")
|
||||
|
||||
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
|
||||
|
||||
assert (profile_dir / "memories" / "note.md").read_text() == "remember this"
|
||||
assert not (profile_dir / "profiles").exists()
|
||||
|
||||
def test_clone_config_missing_files_skipped(self, profile_env):
|
||||
"""Clone config gracefully skips files that don't exist in source."""
|
||||
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
|
||||
|
||||
@@ -12,6 +12,7 @@ def _args(**overrides):
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"resume": None,
|
||||
"toolsets": None,
|
||||
"tui": True,
|
||||
"tui_dev": False,
|
||||
}
|
||||
@@ -35,7 +36,7 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
||||
calls.append(source)
|
||||
return "20260408_235959_a1b2c3" if source == "tui" else None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -62,7 +63,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
||||
return "20260408_235959_d4e5f6"
|
||||
return None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -80,7 +81,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
||||
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
@@ -98,12 +99,13 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
|
||||
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None):
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
captured.update(
|
||||
{
|
||||
"model": model,
|
||||
"provider": provider,
|
||||
"resume": resume_session_id,
|
||||
"toolsets": toolsets,
|
||||
"tui_dev": tui_dev,
|
||||
}
|
||||
)
|
||||
@@ -120,11 +122,193 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"provider": "anthropic",
|
||||
"resume": None,
|
||||
"toolsets": None,
|
||||
"tui_dev": False,
|
||||
}
|
||||
|
||||
|
||||
def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod):
|
||||
def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
captured["toolsets"] = toolsets
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(_args(toolsets="web,terminal"))
|
||||
|
||||
assert captured["toolsets"] == "web,terminal"
|
||||
|
||||
|
||||
def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"])
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
|
||||
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {})
|
||||
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"agent.shell_hooks",
|
||||
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
|
||||
|
||||
main_mod.main()
|
||||
|
||||
assert captured == {"toolsets": "web,terminal", "tui": True}
|
||||
|
||||
|
||||
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"])
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
|
||||
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {})
|
||||
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"agent.shell_hooks",
|
||||
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.oneshot",
|
||||
types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.main()
|
||||
|
||||
assert exc.value.code == 0
|
||||
assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"}
|
||||
|
||||
|
||||
def _stub_plugin_discovery(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
|
||||
def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys):
|
||||
_stub_plugin_discovery(monkeypatch)
|
||||
from hermes_cli.oneshot import run_oneshot
|
||||
|
||||
assert run_oneshot("hello", toolsets="nope") == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "nope" in err
|
||||
assert "did not contain any valid toolsets" in err
|
||||
|
||||
|
||||
def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys):
|
||||
_stub_plugin_discovery(monkeypatch)
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
valid, error = _validate_explicit_toolsets("web,nope")
|
||||
|
||||
assert valid == ["web"]
|
||||
assert error is None
|
||||
assert "nope" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_oneshot_all_toolsets_means_all_not_configured_cli():
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
valid, error = _validate_explicit_toolsets("all")
|
||||
|
||||
assert valid is None
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_oneshot_all_toolsets_warns_about_ignored_extra_entries(monkeypatch, capsys):
|
||||
_stub_plugin_discovery(monkeypatch)
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
valid, error = _validate_explicit_toolsets("all,nope")
|
||||
|
||||
assert valid is None
|
||||
assert error is None
|
||||
assert "ignoring additional entries: nope" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
|
||||
import toolsets
|
||||
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
discovered = {"ready": False}
|
||||
original_validate = toolsets.validate_toolset
|
||||
|
||||
def fake_validate(name):
|
||||
return name == "plugin_demo" and discovered["ready"] or original_validate(name)
|
||||
|
||||
monkeypatch.setattr(toolsets, "validate_toolset", fake_validate)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
|
||||
)
|
||||
|
||||
valid, error = _validate_explicit_toolsets("plugin_demo")
|
||||
|
||||
assert valid == ["plugin_demo"]
|
||||
assert error is None
|
||||
|
||||
|
||||
def test_oneshot_rejects_disabled_mcp_toolset(monkeypatch, capsys):
|
||||
_stub_plugin_discovery(monkeypatch)
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"read_raw_config",
|
||||
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
||||
)
|
||||
|
||||
valid, error = _validate_explicit_toolsets("mcp-off")
|
||||
|
||||
assert valid is None
|
||||
assert error == "hermes -z: --toolsets did not contain any valid toolsets.\n"
|
||||
err = capsys.readouterr().err
|
||||
assert "ignoring disabled MCP servers" in err
|
||||
assert "mcp-off" in err
|
||||
|
||||
|
||||
def test_oneshot_distinguishes_disabled_mcp_from_unknown(monkeypatch, capsys):
|
||||
_stub_plugin_discovery(monkeypatch)
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
from hermes_cli.oneshot import _validate_explicit_toolsets
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"read_raw_config",
|
||||
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
||||
)
|
||||
|
||||
valid, error = _validate_explicit_toolsets("web,mcp-off,nope")
|
||||
|
||||
assert valid == ["web"]
|
||||
assert error is None
|
||||
err = capsys.readouterr().err
|
||||
assert "ignoring unknown --toolsets entries: nope" in err
|
||||
assert "ignoring disabled MCP servers" in err
|
||||
assert "mcp-off" in err
|
||||
|
||||
|
||||
def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
active_path_during_call = None
|
||||
|
||||
@@ -144,13 +328,14 @@ def test_launch_tui_exports_model_and_provider(monkeypatch, main_mod):
|
||||
monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod._launch_tui(model="nous/hermes-test", provider="nous")
|
||||
main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal")
|
||||
|
||||
env = captured["env"]
|
||||
assert env["HERMES_MODEL"] == "nous/hermes-test"
|
||||
assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test"
|
||||
assert env["HERMES_TUI_PROVIDER"] == "nous"
|
||||
assert env["HERMES_INFERENCE_PROVIDER"] == "nous"
|
||||
assert env["HERMES_TUI_TOOLSETS"] == "web,terminal"
|
||||
active_path = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"])
|
||||
assert active_path.name.startswith("hermes-tui-active-session-")
|
||||
assert active_path.suffix == ".json"
|
||||
|
||||
@@ -59,6 +59,147 @@ def test_write_json_returns_false_on_broken_pipe(monkeypatch):
|
||||
assert server.write_json({"ok": True}) is False
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_prefers_tui_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, terminal, ,memory")
|
||||
|
||||
assert server._load_enabled_toolsets() == ["web", "terminal", "memory"]
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_filters_invalid_tui_env(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web, nope")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["web"]
|
||||
assert "nope" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "plugin_demo")
|
||||
|
||||
import toolsets
|
||||
|
||||
discovered = {"ready": False}
|
||||
original_validate = toolsets.validate_toolset
|
||||
|
||||
def fake_validate(name):
|
||||
return name == "plugin_demo" and discovered["ready"] or original_validate(name)
|
||||
|
||||
monkeypatch.setattr(toolsets, "validate_toolset", fake_validate)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["plugin_demo"]
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "mcp-off")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"read_raw_config",
|
||||
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
||||
)
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
|
||||
|
||||
assert server._load_enabled_toolsets() == ["memory"]
|
||||
err = capsys.readouterr().err
|
||||
assert "ignoring disabled MCP servers" in err
|
||||
assert "mcp-off" in err
|
||||
assert "using configured CLI toolsets" in err
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
|
||||
|
||||
assert server._load_enabled_toolsets() == ["memory"]
|
||||
assert "using configured CLI toolsets" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "nope")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
|
||||
assert server._load_enabled_toolsets() is None
|
||||
assert "could not be loaded" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web")
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
|
||||
assert server._load_enabled_toolsets() == ["web"]
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_all_env_means_all(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all")
|
||||
|
||||
assert server._load_enabled_toolsets() is None
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope")
|
||||
|
||||
assert server._load_enabled_toolsets() is None
|
||||
assert "ignoring additional entries: nope" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_reports_disabled_mcp_separately(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "web,mcp-off,nope")
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"read_raw_config",
|
||||
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["web"]
|
||||
err = capsys.readouterr().err
|
||||
assert "ignoring unknown HERMES_TUI_TOOLSETS entries: nope" in err
|
||||
assert "ignoring disabled MCP servers" in err
|
||||
assert "mcp-off" in err
|
||||
|
||||
|
||||
def test_history_to_messages_preserves_tool_calls_for_resume_display():
|
||||
history = [
|
||||
{"role": "user", "content": "first prompt"},
|
||||
@@ -879,6 +1020,36 @@ def test_config_set_statusbar_survives_non_dict_display(tmp_path, monkeypatch):
|
||||
assert saved["display"]["tui_statusbar"] == "bottom"
|
||||
|
||||
|
||||
def test_config_set_details_mode_pins_all_sections(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
cfg_path = tmp_path / "config.yaml"
|
||||
cfg_path.write_text(
|
||||
yaml.safe_dump(
|
||||
{"display": {"sections": {"tools": "expanded", "activity": "hidden"}}}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "config.set",
|
||||
"params": {"key": "details_mode", "value": "collapsed"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"key": "details_mode", "value": "collapsed"}
|
||||
saved = yaml.safe_load(cfg_path.read_text())
|
||||
assert saved["display"]["details_mode"] == "collapsed"
|
||||
assert saved["display"]["sections"] == {
|
||||
"thinking": "collapsed",
|
||||
"tools": "collapsed",
|
||||
"subagents": "collapsed",
|
||||
"activity": "collapsed",
|
||||
}
|
||||
|
||||
|
||||
def test_config_set_section_writes_per_section_override(tmp_path, monkeypatch):
|
||||
import yaml
|
||||
|
||||
@@ -1066,6 +1237,18 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat
|
||||
)
|
||||
assert resp_show["result"]["value"] == "show"
|
||||
assert server._sessions["sid"]["show_reasoning"] is True
|
||||
assert server._load_cfg()["display"]["sections"]["thinking"] == "expanded"
|
||||
|
||||
resp_hide = server.handle_request(
|
||||
{
|
||||
"id": "3",
|
||||
"method": "config.set",
|
||||
"params": {"session_id": "sid", "key": "reasoning", "value": "hide"},
|
||||
}
|
||||
)
|
||||
assert resp_hide["result"]["value"] == "hide"
|
||||
assert server._sessions["sid"]["show_reasoning"] is False
|
||||
assert server._load_cfg()["display"]["sections"]["thinking"] == "hidden"
|
||||
|
||||
|
||||
def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch):
|
||||
|
||||
@@ -128,6 +128,8 @@ _cfg_path = None
|
||||
_SLASH_WORKER_TIMEOUT_S = max(
|
||||
5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45)
|
||||
)
|
||||
_DETAIL_SECTION_NAMES = ("thinking", "tools", "subagents", "activity")
|
||||
_DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
|
||||
|
||||
# ── Async RPC dispatch (#12546) ──────────────────────────────────────
|
||||
# A handful of handlers block the dispatcher loop in entry.py for seconds
|
||||
@@ -859,10 +861,100 @@ def _load_tool_progress_mode() -> str:
|
||||
|
||||
|
||||
def _load_enabled_toolsets() -> list[str] | None:
|
||||
explicit = [
|
||||
item.strip()
|
||||
for item in os.environ.get("HERMES_TUI_TOOLSETS", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
cfg = None
|
||||
fallback_notice = None
|
||||
|
||||
try:
|
||||
from toolsets import validate_toolset
|
||||
except Exception:
|
||||
validate_toolset = None
|
||||
|
||||
if explicit and validate_toolset is not None:
|
||||
built_in = [name for name in explicit if validate_toolset(name)]
|
||||
unresolved = [name for name in explicit if name not in built_in]
|
||||
|
||||
if unresolved:
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
plugin_valid = [name for name in unresolved if validate_toolset(name)]
|
||||
except Exception:
|
||||
plugin_valid = []
|
||||
|
||||
if plugin_valid:
|
||||
built_in.extend(plugin_valid)
|
||||
unresolved = [name for name in unresolved if name not in plugin_valid]
|
||||
|
||||
if any(name in {"all", "*"} for name in built_in):
|
||||
ignored = [name for name in explicit if name not in {"all", "*"}]
|
||||
if ignored:
|
||||
print(
|
||||
"[tui] HERMES_TUI_TOOLSETS=all enables every toolset; "
|
||||
f"ignoring additional entries: {', '.join(ignored)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
if not unresolved:
|
||||
return built_in
|
||||
|
||||
mcp_names: set[str] = set()
|
||||
mcp_disabled: set[str] = set()
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
raw_cfg = read_raw_config()
|
||||
mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
|
||||
for name, server_cfg in mcp_servers.items():
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
if _parse_enabled_flag(server_cfg.get("enabled", True), default=True):
|
||||
mcp_names.add(str(name))
|
||||
else:
|
||||
mcp_disabled.add(str(name))
|
||||
except Exception:
|
||||
mcp_names = set()
|
||||
mcp_disabled = set()
|
||||
|
||||
mcp_valid = [name for name in unresolved if name in mcp_names]
|
||||
disabled = [name for name in unresolved if name in mcp_disabled]
|
||||
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
|
||||
valid = built_in + mcp_valid
|
||||
|
||||
if unknown:
|
||||
print(
|
||||
f"[tui] ignoring unknown HERMES_TUI_TOOLSETS entries: {', '.join(unknown)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if disabled:
|
||||
print(
|
||||
"[tui] ignoring disabled MCP servers in HERMES_TUI_TOOLSETS "
|
||||
"(set enabled: true in config.yaml to use): "
|
||||
f"{', '.join(disabled)}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if valid:
|
||||
return valid
|
||||
|
||||
fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
cfg = cfg if cfg is not None else load_config()
|
||||
|
||||
# Runtime toolset resolution must include default MCP servers so the
|
||||
# agent can actually call them. Passing ``False`` here is the
|
||||
# config-editing variant — used when we need to persist a toolset
|
||||
@@ -870,10 +962,18 @@ def _load_enabled_toolsets() -> list[str] | None:
|
||||
# variant at agent creation time makes MCP tools silently missing
|
||||
# from the TUI. See PR #3252 for the original design split.
|
||||
enabled = sorted(
|
||||
_get_platform_tools(load_config(), "cli", include_default_mcp_servers=True)
|
||||
_get_platform_tools(cfg, "cli", include_default_mcp_servers=True)
|
||||
)
|
||||
if fallback_notice is not None:
|
||||
print(fallback_notice, file=sys.stderr, flush=True)
|
||||
return enabled or None
|
||||
except Exception:
|
||||
if fallback_notice is not None:
|
||||
print(
|
||||
"[tui] no valid HERMES_TUI_TOOLSETS entries and configured CLI toolsets could not be loaded; enabling all toolsets",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -3105,12 +3205,34 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
arg = str(value or "").strip().lower()
|
||||
if arg in ("show", "on"):
|
||||
_write_config_key("display.show_reasoning", True)
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
sections = (
|
||||
display.get("sections")
|
||||
if isinstance(display.get("sections"), dict)
|
||||
else {}
|
||||
)
|
||||
display["show_reasoning"] = True
|
||||
sections["thinking"] = "expanded"
|
||||
display["sections"] = sections
|
||||
cfg["display"] = display
|
||||
_save_cfg(cfg)
|
||||
if session:
|
||||
session["show_reasoning"] = True
|
||||
return _ok(rid, {"key": key, "value": "show"})
|
||||
if arg in ("hide", "off"):
|
||||
_write_config_key("display.show_reasoning", False)
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
sections = (
|
||||
display.get("sections")
|
||||
if isinstance(display.get("sections"), dict)
|
||||
else {}
|
||||
)
|
||||
display["show_reasoning"] = False
|
||||
sections["thinking"] = "hidden"
|
||||
display["sections"] = sections
|
||||
cfg["display"] = display
|
||||
_save_cfg(cfg)
|
||||
if session:
|
||||
session["show_reasoning"] = False
|
||||
return _ok(rid, {"key": key, "value": "hide"})
|
||||
@@ -3127,19 +3249,26 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
if key == "details_mode":
|
||||
nv = str(value or "").strip().lower()
|
||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||
if nv not in allowed_dm:
|
||||
if nv not in _DETAIL_MODES:
|
||||
return _err(rid, 4002, f"unknown details_mode: {value}")
|
||||
_write_config_key("display.details_mode", nv)
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
sections = display.get("sections") if isinstance(display.get("sections"), dict) else {}
|
||||
display["details_mode"] = nv
|
||||
for section in _DETAIL_SECTION_NAMES:
|
||||
sections[section] = nv
|
||||
display["sections"] = sections
|
||||
cfg["display"] = display
|
||||
_save_cfg(cfg)
|
||||
return _ok(rid, {"key": key, "value": nv})
|
||||
|
||||
if key.startswith("details_mode."):
|
||||
# Per-section override: `details_mode.<section>` writes to
|
||||
# `display.sections.<section>`. Empty value clears the override
|
||||
# and lets the section fall back to the global details_mode.
|
||||
# `display.sections.<section>`. Empty value clears the explicit
|
||||
# override and lets frontend resolution apply built-in section defaults
|
||||
# before the global details_mode.
|
||||
section = key.split(".", 1)[1]
|
||||
allowed_sections = frozenset({"thinking", "tools", "subagents", "activity"})
|
||||
if section not in allowed_sections:
|
||||
if section not in _DETAIL_SECTION_NAMES:
|
||||
return _err(rid, 4002, f"unknown section: {section}")
|
||||
|
||||
cfg = _load_cfg()
|
||||
@@ -3156,8 +3285,7 @@ def _(rid, params: dict) -> dict:
|
||||
_save_cfg(cfg)
|
||||
return _ok(rid, {"key": key, "value": ""})
|
||||
|
||||
allowed_dm = frozenset({"hidden", "collapsed", "expanded"})
|
||||
if nv not in allowed_dm:
|
||||
if nv not in _DETAIL_MODES:
|
||||
return _err(rid, 4002, f"unknown details_mode: {value}")
|
||||
|
||||
sections_cfg[section] = nv
|
||||
|
||||
@@ -76,6 +76,45 @@ describe('createSlashHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies /reasoning hide to the thinking section immediately', async () => {
|
||||
patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' })
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
...buildGateway(),
|
||||
rpc: vi.fn(() => Promise.resolve({ value: 'hide' }))
|
||||
}
|
||||
})
|
||||
|
||||
expect(createSlashHandler(ctx)('/reasoning hide')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getUiState().showReasoning).toBe(false)
|
||||
expect(getUiState().sections.thinking).toBe('hidden')
|
||||
})
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: 'sid-abc',
|
||||
value: 'hide'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies /reasoning show to the thinking section immediately', async () => {
|
||||
patchUiState({ sections: { thinking: 'hidden' }, showReasoning: false, sid: 'sid-abc' })
|
||||
const ctx = buildCtx({
|
||||
gateway: {
|
||||
...buildGateway(),
|
||||
rpc: vi.fn(() => Promise.resolve({ value: 'show' }))
|
||||
}
|
||||
})
|
||||
|
||||
expect(createSlashHandler(ctx)('/reasoning show')).toBe(true)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getUiState().showReasoning).toBe(true)
|
||||
expect(getUiState().sections.thinking).toBe('expanded')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the skills hub locally for bare /skills', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
@@ -141,6 +180,12 @@ describe('createSlashHandler', () => {
|
||||
expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
|
||||
expect(getUiState().detailsMode).toBe('expanded')
|
||||
expect(getUiState().detailsModeCommandOverride).toBe(true)
|
||||
expect(getUiState().sections).toEqual({
|
||||
thinking: 'expanded',
|
||||
tools: 'expanded',
|
||||
subagents: 'expanded',
|
||||
activity: 'expanded'
|
||||
})
|
||||
expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
|
||||
key: 'details_mode',
|
||||
value: 'expanded'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { offsetFromPosition } from '../components/textInput.js'
|
||||
import { cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||
|
||||
describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||
describe('cursorLayout — word-wrap parity with wrap-ansi', () => {
|
||||
it('places cursor mid-line at its column', () => {
|
||||
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
|
||||
})
|
||||
@@ -18,12 +18,20 @@ describe('cursorLayout — char-wrap parity with wrap-ansi', () => {
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('tracks a word across a char-wrap boundary without jumping', () => {
|
||||
// With wordWrap:false, "hello world" at cols=8 is "hello wo\nrld" —
|
||||
// typing incremental letters doesn't reshuffle the word across lines.
|
||||
it('moves words across wrap boundaries instead of splitting them', () => {
|
||||
// With wordWrap:true, "hello wor" at cols=8 is "hello \nwor" rather
|
||||
// than "hello wo\nr".
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 })
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 2, line: 1 })
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 })
|
||||
expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 })
|
||||
})
|
||||
|
||||
it('wraps the next word instead of splitting it at the right edge', () => {
|
||||
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
|
||||
|
||||
expect(cursorLayout(text, text.length, 70)).toEqual({ column: 4, line: 1 })
|
||||
expect(inputVisualHeight(text, 70)).toBe(2)
|
||||
})
|
||||
|
||||
it('honours explicit newlines', () => {
|
||||
@@ -42,6 +50,12 @@ describe('input metrics helpers', () => {
|
||||
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts the prompt gap as its own cell', () => {
|
||||
expect(composerPromptWidth('>')).toBe(2)
|
||||
expect(composerPromptWidth('❯')).toBe(2)
|
||||
expect(composerPromptWidth('Ψ >')).toBe(4)
|
||||
})
|
||||
|
||||
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
||||
expect(stableComposerColumns(100, 3)).toBe(93)
|
||||
expect(stableComposerColumns(100, 5)).toBe(91)
|
||||
@@ -50,7 +64,7 @@ describe('input metrics helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
|
||||
describe('offsetFromPosition — word-wrap inverse of cursorLayout', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
|
||||
})
|
||||
@@ -64,11 +78,23 @@ describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => {
|
||||
})
|
||||
|
||||
it('maps clicks on a wrapped second row at cols boundary', () => {
|
||||
// "abcdefghij" at cols=8 wraps to "abcdefgh\nij" — click at row 1 col 0
|
||||
// should land on 'i' (offset 8).
|
||||
// Long words still hard-wrap when there is no word boundary.
|
||||
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
|
||||
})
|
||||
|
||||
it('maps clicks on a word-wrapped second row', () => {
|
||||
// "hello world" at cols=8 wraps to "hello \nworld".
|
||||
expect(offsetFromPosition('hello world', 1, 0, 8)).toBe(6)
|
||||
expect(offsetFromPosition('hello world', 1, 3, 8)).toBe(9)
|
||||
})
|
||||
|
||||
it('maps clicks on the moved final word', () => {
|
||||
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
|
||||
|
||||
expect(offsetFromPosition(text, 1, 0, 70)).toBe(text.indexOf('wish'))
|
||||
expect(offsetFromPosition(text, 1, 3, 70)).toBe(text.indexOf('wish') + 3)
|
||||
})
|
||||
|
||||
it('maps clicks past a \\n into the target line', () => {
|
||||
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
|
||||
})
|
||||
|
||||
@@ -266,7 +266,9 @@ export const coreCommands: SlashCommand[] = [
|
||||
return transcript.sys(DETAILS_USAGE)
|
||||
}
|
||||
|
||||
patchUiState({ detailsMode: next, detailsModeCommandOverride: true })
|
||||
const sections = Object.fromEntries(SECTION_NAMES.map(section => [section, next]))
|
||||
|
||||
patchUiState({ detailsMode: next, detailsModeCommandOverride: true, sections })
|
||||
gateway.rpc<ConfigSetResponse>('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
||||
transcript.sys(`details: ${next}`)
|
||||
}
|
||||
|
||||
@@ -332,7 +332,29 @@ export const sessionCommands: SlashCommand[] = [
|
||||
|
||||
ctx.gateway
|
||||
.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
|
||||
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`)))
|
||||
.then(
|
||||
ctx.guarded<ConfigSetResponse>(r => {
|
||||
if (!r.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (r.value === 'hide') {
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
sections: { ...state.sections, thinking: 'hidden' },
|
||||
showReasoning: false
|
||||
}))
|
||||
} else if (r.value === 'show') {
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
sections: { ...state.sections, thinking: 'expanded' },
|
||||
showReasoning: true
|
||||
}))
|
||||
}
|
||||
|
||||
ctx.transcript.sys(`reasoning: ${r.value}`)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -711,6 +711,9 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const anyPanelVisible = SECTION_NAMES.some(
|
||||
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
)
|
||||
const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
const showProgressArea = useTurnSelector(state =>
|
||||
anyPanelVisible
|
||||
@@ -718,12 +721,25 @@ export function useMainApp(gw: GatewayClient) {
|
||||
ui.busy ||
|
||||
state.outcome ||
|
||||
state.streamPendingTools.length ||
|
||||
state.streamSegments.length ||
|
||||
state.streamSegments.some(segment => {
|
||||
const hasThinking = Boolean(segment.thinking?.trim())
|
||||
const hasTrailTools = Boolean(segment.tools?.length)
|
||||
|
||||
if (segment.kind === 'trail' && !segment.text) {
|
||||
return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
}
|
||||
|
||||
return (
|
||||
Boolean(segment.text?.trim()) ||
|
||||
(thinkingPanelVisible && hasThinking) ||
|
||||
((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
)
|
||||
}) ||
|
||||
state.subagents.length ||
|
||||
state.tools.length ||
|
||||
state.todos.length ||
|
||||
state.turnTrail.length ||
|
||||
hasReasoning ||
|
||||
(thinkingPanelVisible && hasReasoning) ||
|
||||
state.activity.length
|
||||
)
|
||||
: state.activity.some(item => item.tone !== 'info')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, stringWidth, Text } from '@hermes/ink'
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Fragment, memo, useMemo, useRef } from 'react'
|
||||
|
||||
@@ -9,7 +9,12 @@ import { $uiState } from '../app/uiStore.js'
|
||||
import { INLINE_MODE, SHOW_FPS } from '../config/env.js'
|
||||
import { FULL_RENDER_TAIL_ITEMS } from '../config/limits.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||
import {
|
||||
COMPOSER_PROMPT_GAP_WIDTH,
|
||||
composerPromptWidth,
|
||||
inputVisualHeight,
|
||||
stableComposerColumns
|
||||
} from '../lib/inputMetrics.js'
|
||||
import { PerfPane } from '../lib/perfPane.js'
|
||||
|
||||
import { AgentsOverlay } from './agentsOverlay.js'
|
||||
@@ -22,6 +27,31 @@ import { QueuedMessages } from './queuedMessages.js'
|
||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||
import { TextInput, type TextInputMouseApi } from './textInput.js'
|
||||
|
||||
const PromptPrefix = memo(function PromptPrefix({
|
||||
bold = false,
|
||||
color,
|
||||
promptText,
|
||||
width
|
||||
}: {
|
||||
bold?: boolean
|
||||
color: string
|
||||
promptText: string
|
||||
width: number
|
||||
}) {
|
||||
const glyphWidth = Math.max(1, width - COMPOSER_PROMPT_GAP_WIDTH)
|
||||
|
||||
return (
|
||||
<Box width={width}>
|
||||
<Box width={glyphWidth}>
|
||||
<Text bold={bold} color={color}>
|
||||
{promptText}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={COMPOSER_PROMPT_GAP_WIDTH} />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
const TranscriptPane = memo(function TranscriptPane({
|
||||
actions,
|
||||
composer,
|
||||
@@ -125,8 +155,8 @@ const ComposerPane = memo(function ComposerPane({
|
||||
const isBlocked = useStore($isBlocked)
|
||||
const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!')
|
||||
const promptText = sh ? '$' : ui.theme.brand.prompt
|
||||
const promptLabel = `${promptText} `
|
||||
const promptWidth = Math.max(1, stringWidth(promptLabel))
|
||||
const promptWidth = composerPromptWidth(promptText)
|
||||
const promptBlank = ' '.repeat(promptWidth)
|
||||
const inputColumns = stableComposerColumns(composer.cols, promptWidth)
|
||||
const inputHeight = inputVisualHeight(composer.input, inputColumns)
|
||||
const inputMouseRef = useRef<null | TextInputMouseApi>(null)
|
||||
@@ -217,7 +247,11 @@ const ComposerPane = memo(function ComposerPane({
|
||||
{composer.inputBuf.map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Box width={promptWidth}>
|
||||
<Text color={ui.theme.color.muted}>{i === 0 ? promptLabel : ' '.repeat(promptWidth)}</Text>
|
||||
{i === 0 ? (
|
||||
<PromptPrefix color={ui.theme.color.muted} promptText={promptText} width={promptWidth} />
|
||||
) : (
|
||||
<Text color={ui.theme.color.muted}>{promptBlank}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text color={ui.theme.color.text}>{line || ' '}</Text>
|
||||
@@ -229,18 +263,19 @@ const ComposerPane = memo(function ComposerPane({
|
||||
onMouseDrag={dragFromPromptRow}
|
||||
onMouseUp={endInputDrag}
|
||||
position="relative"
|
||||
width={Math.max(1, composer.cols - 2)}
|
||||
>
|
||||
<Box width={promptWidth}>
|
||||
{sh ? (
|
||||
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>
|
||||
<PromptPrefix color={ui.theme.color.shellDollar} promptText={promptText} width={promptWidth} />
|
||||
) : composer.inputBuf.length ? (
|
||||
<Text color={ui.theme.color.prompt}>{promptBlank}</Text>
|
||||
) : (
|
||||
<Text bold color={ui.theme.color.prompt}>
|
||||
{composer.inputBuf.length ? ' '.repeat(promptWidth) : promptLabel}
|
||||
</Text>
|
||||
<PromptPrefix bold color={ui.theme.color.prompt} promptText={promptText} width={promptWidth} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={0} flexShrink={0} height={inputHeight} position="relative" width={inputColumns}>
|
||||
<Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}>
|
||||
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
|
||||
<TextInput
|
||||
columns={inputColumns}
|
||||
@@ -251,10 +286,10 @@ const ComposerPane = memo(function ComposerPane({
|
||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||
value={composer.input}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box position="absolute" right={0}>
|
||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||
</Box>
|
||||
<Box position="absolute" right={0}>
|
||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'rea
|
||||
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||
import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
|
||||
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
@@ -170,57 +170,7 @@ export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
|
||||
return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
|
||||
}
|
||||
|
||||
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
if (!value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const targetRow = Math.max(0, Math.floor(row))
|
||||
const targetCol = Math.max(0, Math.floor(col))
|
||||
const w = Math.max(1, cols)
|
||||
|
||||
let line = 0
|
||||
let column = 0
|
||||
let lastOffset = 0
|
||||
|
||||
for (const { segment, index } of seg().segment(value)) {
|
||||
lastOffset = index
|
||||
|
||||
if (segment === '\n') {
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
|
||||
line++
|
||||
column = 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const sw = Math.max(1, stringWidth(segment))
|
||||
|
||||
if (column + sw > w) {
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
|
||||
line++
|
||||
column = 0
|
||||
}
|
||||
|
||||
if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) {
|
||||
return index
|
||||
}
|
||||
|
||||
column += sw
|
||||
}
|
||||
|
||||
if (targetRow >= line) {
|
||||
return value.length
|
||||
}
|
||||
|
||||
return lastOffset
|
||||
}
|
||||
export { offsetFromPosition }
|
||||
|
||||
function renderWithCursor(value: string, cursor: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
@@ -1059,7 +1009,7 @@ export function TextInput({
|
||||
ref={boxRef}
|
||||
width={columns}
|
||||
>
|
||||
<Text wrap="wrap-char">{rendered}</Text>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +1,167 @@
|
||||
import { stringWidth } from '@hermes/ink'
|
||||
|
||||
export const COMPOSER_PROMPT_GAP_WIDTH = 1
|
||||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||
|
||||
interface VisualLine {
|
||||
end: number
|
||||
start: number
|
||||
}
|
||||
|
||||
const isWhitespace = (value: string) => /\s/.test(value)
|
||||
|
||||
const graphemes = (value: string) =>
|
||||
[...seg().segment(value)].map(({ segment, index }) => ({
|
||||
end: index + segment.length,
|
||||
index,
|
||||
segment,
|
||||
width: Math.max(1, stringWidth(segment))
|
||||
}))
|
||||
|
||||
function visualLines(value: string, cols: number): VisualLine[] {
|
||||
const width = Math.max(1, cols)
|
||||
const lines: VisualLine[] = []
|
||||
let sourceLineStart = 0
|
||||
|
||||
for (const sourceLine of value.split('\n')) {
|
||||
const parts = graphemes(sourceLine)
|
||||
|
||||
if (!parts.length) {
|
||||
lines.push({ start: sourceLineStart, end: sourceLineStart })
|
||||
sourceLineStart += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let lineStartPart = 0
|
||||
let lineStartOffset = sourceLineStart
|
||||
let column = 0
|
||||
let breakPart: null | number = null
|
||||
let i = 0
|
||||
|
||||
while (i < parts.length) {
|
||||
const part = parts[i]!
|
||||
const partStart = sourceLineStart + part.index
|
||||
|
||||
if (column + part.width > width && i > lineStartPart) {
|
||||
if (breakPart !== null && breakPart > lineStartPart) {
|
||||
const breakOffset = sourceLineStart + parts[breakPart - 1]!.end
|
||||
lines.push({ start: lineStartOffset, end: breakOffset })
|
||||
lineStartPart = breakPart
|
||||
lineStartOffset = breakOffset
|
||||
} else {
|
||||
lines.push({ start: lineStartOffset, end: partStart })
|
||||
lineStartPart = i
|
||||
lineStartOffset = partStart
|
||||
}
|
||||
|
||||
column = 0
|
||||
breakPart = null
|
||||
i = lineStartPart
|
||||
continue
|
||||
}
|
||||
|
||||
column += part.width
|
||||
|
||||
if (isWhitespace(part.segment)) {
|
||||
breakPart = i + 1
|
||||
}
|
||||
|
||||
i += 1
|
||||
|
||||
if (column >= width && i < parts.length) {
|
||||
const next = parts[i]!
|
||||
const nextStartsWord = !isWhitespace(next.segment)
|
||||
|
||||
if (breakPart !== null && breakPart > lineStartPart && nextStartsWord) {
|
||||
const breakOffset = sourceLineStart + parts[breakPart - 1]!.end
|
||||
lines.push({ start: lineStartOffset, end: breakOffset })
|
||||
lineStartPart = breakPart
|
||||
lineStartOffset = breakOffset
|
||||
column = 0
|
||||
breakPart = null
|
||||
i = lineStartPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push({ start: lineStartOffset, end: sourceLineStart + sourceLine.length })
|
||||
sourceLineStart += sourceLine.length + 1
|
||||
}
|
||||
|
||||
return lines.length ? lines : [{ start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
function widthBetween(value: string, start: number, end: number) {
|
||||
let width = 0
|
||||
|
||||
for (const part of graphemes(value.slice(start, end))) {
|
||||
width += part.width
|
||||
}
|
||||
|
||||
return width
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the char-wrap behavior used by the composer TextInput.
|
||||
* Mirrors the word-wrap behavior used by the composer TextInput.
|
||||
* Returns the zero-based visual line and column of the cursor cell.
|
||||
*/
|
||||
export function cursorLayout(value: string, cursor: number, cols: number) {
|
||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||
const w = Math.max(1, cols)
|
||||
const lines = visualLines(value, w)
|
||||
let lineIndex = 0
|
||||
|
||||
let col = 0,
|
||||
line = 0
|
||||
|
||||
for (const { segment, index } of seg().segment(value)) {
|
||||
if (index >= pos) {
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (lines[i]!.start <= pos) {
|
||||
lineIndex = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
if (segment === '\n') {
|
||||
line++
|
||||
col = 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const sw = stringWidth(segment)
|
||||
|
||||
if (!sw) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (col + sw > w) {
|
||||
line++
|
||||
col = 0
|
||||
}
|
||||
|
||||
col += sw
|
||||
}
|
||||
|
||||
const line = lines[lineIndex]!
|
||||
let column = widthBetween(value, line.start, Math.min(pos, line.end))
|
||||
|
||||
// trailing cursor-cell overflows to the next row at the wrap column
|
||||
if (col >= w) {
|
||||
line++
|
||||
col = 0
|
||||
if (column >= w) {
|
||||
lineIndex++
|
||||
column = 0
|
||||
}
|
||||
|
||||
return { column: col, line }
|
||||
return { column, line: lineIndex }
|
||||
}
|
||||
|
||||
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
|
||||
if (!value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const lines = visualLines(value, cols)
|
||||
const target = lines[Math.max(0, Math.min(lines.length - 1, Math.floor(row)))]!
|
||||
const targetCol = Math.max(0, Math.floor(col))
|
||||
let column = 0
|
||||
|
||||
for (const part of graphemes(value.slice(target.start, target.end))) {
|
||||
if (targetCol <= column + Math.max(0, part.width - 1)) {
|
||||
return target.start + part.index
|
||||
}
|
||||
|
||||
column += part.width
|
||||
}
|
||||
|
||||
return target.end
|
||||
}
|
||||
|
||||
export function inputVisualHeight(value: string, columns: number) {
|
||||
return cursorLayout(value, value.length, columns).line + 1
|
||||
}
|
||||
|
||||
export function composerPromptWidth(promptText: string) {
|
||||
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
|
||||
}
|
||||
|
||||
export function stableComposerColumns(totalCols: number, promptWidth: number) {
|
||||
// Physical render/wrap width. Always reserve outer composer padding and
|
||||
// prompt prefix. Only reserve the transcript scrollbar gutter when the
|
||||
|
||||
@@ -87,7 +87,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. |
|
||||
| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. Pass `--delete` (or `-d`) — e.g. `/exit --delete` — to also permanently remove the current session's SQLite history and on-disk transcripts before exiting. Useful for privacy-sensitive or one-off tasks. |
|
||||
|
||||
### Dynamic CLI slash commands
|
||||
|
||||
|
||||
Reference in New Issue
Block a user