Compare commits

...

17 Commits

Author SHA1 Message Date
teknium1
9845650fd6 feat(cli): add /exit --delete flag to remove session on quit
Port from google-gemini/gemini-cli#19332.

Users can now exit with '/exit --delete' (or '/quit --delete', '/exit -d')
to permanently remove the current session's SQLite history plus on-disk
transcripts (*.json / *.jsonl / request_dump_*) in one shot. Useful for
privacy-sensitive workflows and one-off interactions where leaving a
session recording behind is undesirable.

Implementation:
- New HermesCLI._delete_session_on_exit one-shot flag (defaults False).
- process_command() parses --delete / -d after /exit or /quit and arms
  the flag. Unknown args print a hint and keep the CLI running (prevents
  typos like '/exit -delete' from accidentally exiting).
- Shutdown path calls SessionDB.delete_session(session_id, sessions_dir=...)
  right after end_session() when the flag is set. That API already
  existed for 'hermes sessions delete' and handles both SQLite removal
  (orphaning child sessions so FK constraints hold) and on-disk file
  cleanup.
- /quit CommandDef now advertises '[--delete]' in args_hint so /help
  and CLI autocomplete surface it.

Tests: tests/cli/test_exit_delete_session.py (12 cases covering both
aliases, case insensitivity, whitespace, short form, unknown-arg
rejection, and registry metadata).

E2E-verified with isolated HERMES_HOME: session row deleted, all three
transcript/request-dump files removed, second delete_session call
correctly returns False.
2026-04-29 17:05:23 -07:00
brooklyn!
98f5be13fa fix(tui): word-wrap composer input (#17651)
* fix(tui): word-wrap composer input

Wrap composer input at word boundaries and anchor the good-vibes heart to the full composer row.

* test(tui): cover composer word wrap edge

Add regression coverage for moving the next word instead of splitting it at the composer edge.
2026-04-29 16:55:49 -07:00
brooklyn!
5e6e8b6af3 fix(tui): honor launch toolsets (#17623)
* fix(tui): honor launch toolsets

Carry chat --toolsets through the TUI launcher so TUI sessions use the same per-session tool scope as the classic CLI.

* fix(tui): parse top-level toolsets flag

Allow top-level hermes --tui --toolsets to reach the implicit chat session, matching chat subcommand behavior.

* fix(tui): validate launch toolsets

Filter invalid HERMES_TUI_TOOLSETS entries and fall back to configured CLI toolsets when the override contains no valid toolsets.

* fix(tui): avoid config load for builtin toolsets

Honor built-in HERMES_TUI_TOOLSETS values before loading config and treat all/* as the all-toolsets sentinel.

* fix(cli): honor toolsets in oneshot mode

Forward top-level --toolsets into oneshot agent construction so the flag is not silently ignored outside the TUI path.

* fix(cli): validate oneshot toolsets

Reject invalid-only oneshot toolset overrides before output redirection and clarify TUI fallback warnings.

* fix(cli): preserve all-toolsets sentinel

Map explicit all/* oneshot toolset overrides to the all-toolsets sentinel and replace locals() checks in TUI toolset loading.

* fix(cli): warn on extra all-toolset entries

Warn when all/* toolset overrides include additional ignored entries so typos are still visible.

* fix(tui): honor plugin toolset overrides

Discover plugin toolsets before rejecting unresolved explicit toolset overrides and read raw config for MCP name validation.

* fix(tui): reuse toolset argument normalizer

Share top-level TUI toolset argument parsing with the oneshot path to avoid duplicate normalization logic.

* fix(cli): reject disabled mcp toolsets

Validate explicit toolset overrides against enabled MCP servers only and clarify top-level toolset flag help.

* fix(cli): distinguish disabled mcp from unknown toolsets

Report disabled MCP servers separately from unknown toolset entries and stub plugin discovery in invalid-name tests for determinism.
2026-04-29 16:55:27 -07:00
brooklyn!
d9bf093728 Merge pull request #17638 from NousResearch/bb/tui-details-persist
fix(tui): persist global details mode sections
2026-04-29 15:15:37 -07:00
Brooklyn Nicholson
faa467ccaf fix(tui): share detail section constants
Reuse one gateway detail-section list for global and per-section detail mode config handling.
2026-04-29 17:05:51 -05:00
brooklyn!
f45434d3c6 Merge pull request #17626 from NousResearch/bb/tui-prompt-gap
fix(tui): render explicit prompt gap
2026-04-29 14:58:17 -07:00
brooklyn!
2a9a5fffa5 Merge pull request #17625 from NousResearch/bb/tui-reasoning-hide
fix(tui): hide reasoning panels immediately
2026-04-29 14:49:20 -07:00
Brooklyn Nicholson
c2cb6d1071 fix(tui): persist global details mode sections
Pin all detail sections when /details sets a global mode so config sync does not restore built-in section defaults.
2026-04-29 16:46:42 -05:00
teknium1
b52b63396c chore: map hejuntt1014 in AUTHOR_MAP 2026-04-29 14:21:35 -07:00
hejuntt1014
528e7dc176 fix(cli): exclude profiles/ from profile create --clone-all
shutil.copytree from default ~/.hermes duplicated ~/.hermes/profiles into
the new profile, causing nested profiles/.../profiles/... and huge disk use.
Match export behavior (_DEFAULT_EXPORT_EXCLUDE_ROOT) by ignoring the sibling
profiles tree at the source root.

Made-with: Cursor
2026-04-29 14:21:35 -07:00
Teknium
4899bd99c0 feat(skills): move comfyui from optional to built-in (#17631)
Intended placement per PR #17610 discussion — comfyui belongs in
skills/creative/ alongside other creative built-ins (touchdesigner-mcp,
pretext, sketch), not in optional-skills/.

Pure directory rename, no content changes. History preserved via git mv.
2026-04-29 14:09:17 -07:00
Brooklyn Nicholson
8652d47eaa fix(tui): remove unused prompt import
Drop the stale stringWidth import after centralizing composer prompt width metrics.
2026-04-29 16:04:22 -05:00
Brooklyn Nicholson
7d96a5ab6e fix(tui): refine reasoning visibility updates
Save reasoning display changes atomically and keep trail segments visible when Activity can render them.
2026-04-29 16:03:45 -05:00
Brooklyn Nicholson
d3ab2b2e13 fix(tui): share composer prompt gap metric
Use one exported prompt gap constant for both composer width math and prompt prefix rendering.
2026-04-29 15:50:54 -05:00
Brooklyn Nicholson
f7abcb4f01 fix(tui): ignore hidden reasoning stream segments
Only keep the live progress area mounted for stream segments that can render under the current detail section visibility.
2026-04-29 15:50:02 -05:00
Brooklyn Nicholson
10fcd620d2 fix(tui): render explicit prompt gap
Reserve the composer prompt gap as layout instead of relying on terminal handling of trailing spaces.
2026-04-29 15:25:06 -05:00
Brooklyn Nicholson
d8afafd22b fix(tui): hide reasoning panels immediately
Make /reasoning hide update the thinking section visibility so existing and live reasoning blocks disappear without waiting for config sync.
2026-04-29 15:23:14 -05:00
29 changed files with 1179 additions and 138 deletions

28
cli.py
View File

@@ -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

View File

@@ -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]"),
]

View File

@@ -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.54GB 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

View File

@@ -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"),

View File

@@ -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)

View File

@@ -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",

View 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]"

View File

@@ -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)

View File

@@ -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"

View File

@@ -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):

View File

@@ -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

View File

@@ -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'

View File

@@ -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)
})

View File

@@ -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}`)
}

View File

@@ -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}`)
})
)
}
},

View File

@@ -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')

View File

@@ -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>
</>

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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