feat(cli): declutter /help + Ctrl+P command palette (C-04/C-05)

This commit is contained in:
Teknium
2026-08-20 03:26:55 -07:00
parent 1179f148e4
commit 05b4ab0ceb
4 changed files with 471 additions and 22 deletions
+346 -19
View File
@@ -5490,6 +5490,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._composer_placeholder = get_random_composer_placeholder()
except Exception:
self._composer_placeholder = ""
self._command_palette_state = None
# Armed when a bare `/resume` prints the recent-sessions list so the
# very next bare numeric input (e.g. `3`) resolves to that session.
# Holds the exact list used for index resolution; one-shot (cleared on
@@ -7076,7 +7077,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
def _get_status_bar_fragments(self):
if not self._status_bar_visible or getattr(self, '_model_picker_state', None):
if not self._status_bar_visible or getattr(self, '_model_picker_state', None) or getattr(self, '_command_palette_state', None):
return []
try:
snapshot = self._get_status_bar_snapshot()
@@ -9256,9 +9257,31 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return self._fast_command_available()
return True
def show_help(self):
"""Display help information with categorized commands."""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
def show_help(self, arg: str = ""):
"""Display help. Bare /help shows categorized core commands with the
skill list collapsed to one line; /help skills lists all skill
commands; /help <query> filters commands by substring.
"""
from hermes_cli.commands import COMMANDS_BY_CATEGORY, HELP_SESSION_SUBGROUPS
arg = (arg or "").strip()
skill_commands = _ensure_skill_commands()
# /help skills — the full skill-command list (kept out of the default
# view so core commands don't scroll off screen).
if arg.lower() in ("skills", "skill"):
if not skill_commands:
_cprint("\n No skill commands installed.\n")
return
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):")
for cmd, info in sorted(skill_commands.items()):
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
_cprint("")
return
query = arg.lower() if arg else ""
try:
from hermes_cli.skin_engine import get_active_help_header
@@ -9273,23 +9296,76 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
_cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}")
_cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}")
def _emit(cmd: str, desc: str) -> bool:
if not self._command_available(cmd):
return False
if query and query not in cmd.lower() and query not in desc.lower():
return False
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}"
)
return True
for category, commands in COMMANDS_BY_CATEGORY.items():
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
if category == "Session":
# Split the oversized Session category into readable sub-groups
# (Session / Context / Background & Automation) in the renderer.
sub_of: dict[str, str] = {}
for _sub, _names in HELP_SESSION_SUBGROUPS.items():
for _n in _names:
sub_of[f"/{_n}"] = _sub
buckets: dict[str, list[tuple[str, str]]] = {"Session": []}
for _sub in HELP_SESSION_SUBGROUPS:
buckets[_sub] = []
for cmd, desc in commands.items():
buckets[sub_of.get(cmd, "Session")].append((cmd, desc))
for _sub in ("Session", *HELP_SESSION_SUBGROUPS.keys()):
rows = buckets.get(_sub) or []
printed_header = False
for cmd, desc in rows:
if not self._command_available(cmd):
continue
if query and query not in cmd.lower() and query not in desc.lower():
continue
if not printed_header:
_cprint(f"\n {_BOLD}── {_sub} ──{_RST}")
printed_header = True
_emit(cmd, desc)
continue
printed_header = False
for cmd, desc in commands.items():
if not self._command_available(cmd):
continue
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
if query and query not in cmd.lower() and query not in desc.lower():
continue
if not printed_header:
_cprint(f"\n {_BOLD}── {category} ──{_RST}")
printed_header = True
_emit(cmd, desc)
skill_commands = _ensure_skill_commands()
if skill_commands:
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):")
for cmd, info in sorted(skill_commands.items()):
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
# Skill commands: collapsed to a one-line pointer by default so the
# 60+ skill entries don't bury the core command reference (C-04).
if query:
# In filter mode, DO include matching skill commands inline.
matched_skills = [
(cmd, info) for cmd, info in sorted(skill_commands.items())
if query in cmd.lower() or query in (info.get("description", "").lower())
]
if matched_skills:
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} (matching '{arg}'):")
for cmd, info in matched_skills:
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
elif skill_commands:
_cprint(
f"\n ⚡ {_BOLD}Skill Commands{_RST}: {len(skill_commands)} installed "
f"— {_DIM}/help skills{_RST} to list them"
)
_bundles_now = get_skill_bundles()
if _bundles_now:
if _bundles_now and not query:
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(_bundles_now)} installed):")
for cmd, info in sorted(_bundles_now.items()):
skill_count = len(info.get("skills", []))
@@ -9300,7 +9376,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
)
quick_commands = self.config.get("quick_commands", {})
if quick_commands:
if quick_commands and not query:
_cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
for name, qcmd in sorted(quick_commands.items()):
desc = qcmd.get("description", qcmd.get("type", ""))
@@ -9308,8 +9384,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Ctrl+J, Alt+Enter, or \\+Enter for a new line{_RST}")
if query:
_cprint(f"\n {_DIM}Filtered by '{arg}' — run /help for the full list.{_RST}\n")
return
_cprint(f"\n {_DIM}Tip: /help skills lists skill commands · /help <text> filters · Ctrl+P opens the command palette{_RST}")
_cprint(f" {_DIM}Multi-line: Ctrl+J, Alt+Enter, or \\\\+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
if _is_termux_environment():
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
@@ -10576,6 +10656,121 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n'))
return lines
def _build_command_palette_entries(self) -> list:
"""Flat list of (command, description) for the Ctrl+P palette.
Sourced from the same COMMAND_REGISTRY that backs /help, filtered to
commands available on this surface, plus installed skill commands.
Selecting an entry inserts the exact command string — never a fuzzy
resolution.
"""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
entries: list[tuple[str, str, str]] = [] # (command, category, desc)
for category, commands in COMMANDS_BY_CATEGORY.items():
for cmd, desc in commands.items():
if not self._command_available(cmd):
continue
entries.append((cmd, category, desc))
try:
for cmd, info in sorted(_ensure_skill_commands().items()):
entries.append((cmd, "Skill", info.get("description", "")))
except Exception:
pass
return entries
def _open_command_palette(self) -> None:
"""Open the Ctrl+P fuzzy command palette modal."""
if getattr(self, "_command_palette_state", None):
return
# Don't stack over other modals.
if (self._model_picker_state or self._clarify_state or self._approval_state
or self._slash_confirm_state or self._sudo_state or self._secret_state):
return
self._capture_modal_input_snapshot()
self._command_palette_state = {
"entries": self._build_command_palette_entries(),
"filter": "",
"selected": 0,
"_scroll_offset": 0,
}
self._invalidate(min_interval=0.0)
def _close_command_palette(self) -> None:
self._command_palette_state = None
self._restore_modal_input_snapshot()
self._invalidate(min_interval=0.0)
def _command_palette_visible_entries(self) -> list:
"""Return (command, category, desc) rows matching the active filter.
Ranked, command-name-focused matching (a bare subsequence over the
whole "cmd category desc" string is uselessly permissive — "steer"
would match 130+ rows via description text). Priority:
0 exact command match
1 command startswith query
2 query substring in command
3 query subsequence in command
4 query substring in description
Rows that match nowhere are dropped. Ties keep registry order.
"""
state = self._command_palette_state or {}
entries = state.get("entries") or []
q = (state.get("filter", "") or "").strip().lower()
if not q:
return list(entries)
def _subseq(needle: str, hay: str) -> bool:
it = iter(hay)
return all(ch in it for ch in needle)
ranked = []
for order, row in enumerate(entries):
cmd, _cat, desc = row
name = cmd.lower().lstrip("/")
qn = q.lstrip("/")
desc_l = (desc or "").lower()
if name == qn:
rank = 0
elif name.startswith(qn):
rank = 1
elif qn in name:
rank = 2
elif _subseq(qn, name):
rank = 3
elif q in desc_l:
rank = 4
else:
continue
ranked.append((rank, order, row))
ranked.sort(key=lambda t: (t[0], t[1]))
return [row for (_r, _o, row) in ranked]
def _handle_command_palette_selection(self) -> None:
"""Insert the selected command into the composer (does not auto-run)."""
state = self._command_palette_state
if not state:
return
rows = self._command_palette_visible_entries()
selected = state.get("selected", 0)
if not (0 <= selected < len(rows)):
self._close_command_palette()
return
cmd = rows[selected][0] # exact command string, e.g. "/model"
self._close_command_palette()
# Prefill the composer so the user can add args / confirm — never
# auto-execute (a palette pick should be explicit, and many commands
# take arguments).
try:
app = getattr(self, "_app", None)
if app is not None:
buf = app.current_buffer
buf.text = cmd + " "
buf.cursor_position = len(buf.text)
self._invalidate(min_interval=0.0)
except Exception:
logger.debug("command palette prefill failed", exc_info=True)
def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None:
"""Open prompt_toolkit-native /model picker modal."""
self._capture_modal_input_snapshot()
@@ -11627,7 +11822,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
return True
return False
elif canonical == "help":
self.show_help()
_help_parts = cmd_original.split(None, 1)
self.show_help(_help_parts[1].strip() if len(_help_parts) > 1 else "")
elif canonical == "palette":
self._open_command_palette()
elif canonical == "whoami":
self._handle_whoami_command()
elif canonical == "profile":
@@ -17100,6 +17298,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
slash_confirm_widget=None,
clarify_widget,
model_picker_widget=None,
command_palette_widget=None,
spinner_widget=None,
spacer,
status_bar,
@@ -17125,6 +17324,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
slash_confirm_widget,
clarify_widget,
model_picker_widget,
command_palette_widget,
spinner_widget,
spacer,
*self._get_extra_tui_widgets(),
@@ -18143,6 +18343,69 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
event.app.current_buffer.reset()
event.app.invalidate()
# --- Ctrl+P command palette keybindings ---
def _palette_active() -> bool:
return bool(self._command_palette_state)
@kb.add('c-p', filter=Condition(
lambda: not self._command_palette_state
and not self._model_picker_state and not self._clarify_state
and not self._approval_state and not self._slash_confirm_state
and not self._sudo_state and not self._secret_state
))
def open_command_palette(event):
self._open_command_palette()
event.app.invalidate()
@kb.add('up', filter=Condition(_palette_active))
def command_palette_up(event):
st = self._command_palette_state
if st:
st["selected"] = max(0, st.get("selected", 0) - 1)
event.app.invalidate()
@kb.add('down', filter=Condition(_palette_active))
def command_palette_down(event):
st = self._command_palette_state
if st:
n = st.get("_visible_count", len(self._command_palette_visible_entries()))
st["selected"] = min(max(0, n - 1), st.get("selected", 0) + 1)
event.app.invalidate()
@kb.add('enter', filter=Condition(_palette_active))
def command_palette_enter(event):
self._handle_command_palette_selection()
event.app.invalidate()
@kb.add('backspace', filter=Condition(_palette_active))
def command_palette_backspace(event):
st = self._command_palette_state
if st:
st["filter"] = (st.get("filter", "") or "")[:-1]
st["selected"] = 0
st["_scroll_offset"] = 0
event.app.invalidate()
@kb.add('escape', filter=Condition(_palette_active), eager=True)
def command_palette_escape(event):
self._close_command_palette()
event.app.invalidate()
def _make_palette_char_handler(ch: str):
def handler(event):
st = self._command_palette_state
if not st:
return
st["filter"] = (st.get("filter", "") or "") + ch
st["selected"] = 0
st["_scroll_offset"] = 0
event.app.invalidate()
return handler
import string as _pstring
for _pch in _pstring.digits + _pstring.ascii_letters + "-_.:/ ":
kb.add(_pch, filter=Condition(_palette_active))(_make_palette_char_handler(_pch))
# Number keys for quick approval selection (1-9, 0 for 10th item)
def _make_approval_number_handler(idx):
def handler(event):
@@ -18176,7 +18439,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Buffer.auto_up/auto_down handle both: cursor movement when multi-line,
# history browsing when on the first/last line (or single-line input).
_normal_input = Condition(
lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state
lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state and not self._command_palette_state
)
def _recall_without_recollapse(buf, move):
@@ -18264,6 +18527,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
event.app.invalidate()
return
# Cancel command palette (foreground UI — cancel and stop here).
if self._command_palette_state:
self._close_command_palette()
event.app.current_buffer.reset()
event.app.invalidate()
return
# Clear all agent-blocking overlays (approval/clarify/sudo/secret)
# in one shot. We do NOT return after clearing — we fall through so
# that if the agent is also running we fire the interrupt on the same
@@ -19501,6 +19771,62 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
filter=Condition(lambda: cli_ref._model_picker_state is not None),
)
# --- Ctrl+P command palette: display widget ---
def _get_command_palette_display():
state = cli_ref._command_palette_state
if not state:
return []
rows = cli_ref._command_palette_visible_entries()
state["_visible_count"] = len(rows)
_query = state.get("filter", "") or ""
total = len(state.get("entries") or [])
title = "⚙ Command Palette"
if _query:
hint = f"Filter: {_query}▏ ({len(rows)}/{total} match — Enter inserts, Esc cancels)"
else:
hint = f"Type to filter {total} commands — ↑/↓ then Enter inserts, Esc cancels"
labels = [f"{c} — {d}" if d else c for (c, _cat, d) in rows]
if not labels:
labels = ["(no matching commands)"]
box_width = _panel_box_width(title, [hint] + labels, min_width=50, max_width=90)
inner_text_width = max(8, box_width - 6)
selected = state.get("selected", 0)
try:
from prompt_toolkit.application import get_app
term_rows = get_app().output.get_size().rows
except Exception:
term_rows = shutil.get_terminal_size((100, 24)).lines
scroll_offset, visible = HermesCLI._compute_model_picker_viewport(
selected, state.get("_scroll_offset", 0), len(labels), term_rows,
)
state["_scroll_offset"] = scroll_offset
lines = []
lines.append(('class:clarify-border', '╭─ '))
lines.append(('class:clarify-title', title))
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len(title) - 3)) + '╮\n'))
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-hint', hint, box_width)
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
for idx in range(scroll_offset, min(scroll_offset + visible, len(labels))):
label = labels[idx]
style = 'class:clarify-selected' if idx == selected else 'class:clarify-choice'
prefix = '❯ ' if idx == selected else ' '
for wrapped in _wrap_panel_text(prefix + label, inner_text_width, subsequent_indent=' '):
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
return lines
command_palette_widget = ConditionalContainer(
Window(
FormattedTextControl(_get_command_palette_display),
wrap_lines=True,
),
filter=Condition(lambda: cli_ref._command_palette_state is not None),
)
# Horizontal rules above and below the input.
# On narrow/mobile terminals we keep the top separator for structure but
# hide the bottom one to recover a full row for conversation content.
@@ -19605,6 +19931,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
slash_confirm_widget=slash_confirm_widget,
clarify_widget=clarify_widget,
model_picker_widget=model_picker_widget,
command_palette_widget=command_palette_widget,
spinner_widget=spinner_widget,
spacer=spacer,
status_bar=status_bar,
+22 -2
View File
@@ -358,8 +358,10 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
gateway_only=True, args_hint="[page]", busy_policy="dispatch",
execute="gateway_commands"),
CommandDef("help", "Show available commands", "Info", busy_policy="dispatch",
execute="gateway_help"),
CommandDef("help", "Show available commands (/help skills lists skill commands, /help <text> filters)", "Info", busy_policy="dispatch",
execute="gateway_help", args_hint="[skills|<filter>]"),
CommandDef("palette", "Open the fuzzy command palette (also Ctrl+P)", "Info",
cli_only=True, busy_policy="dispatch"),
CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session",
gateway_only=True, busy_policy="dispatch"),
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
@@ -448,6 +450,24 @@ for _cmd in COMMAND_REGISTRY:
if _cmd.subcommands:
SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
# Help renderer sub-grouping: the "Session" category accumulated ~46 commands
# spanning genuinely different concerns (lifecycle, context, background/async).
# Rather than re-tag every CommandDef (category is load-bearing for gateway
# help + other surfaces), the /help renderer splits Session into readable
# sub-headers using these command-name sets. Any Session command not listed
# here falls under the base "Session" header. Names are bare (no leading /).
HELP_SESSION_SUBGROUPS: dict[str, tuple[str, ...]] = {
"Context": (
"compress", "compact", "context", "ctx", "status",
),
"Background & Automation": (
"background", "bg", "btw", "agents", "tasks", "queue", "q", "steer",
"goal", "subgoal", "heartbeat", "hb", "refine", "loop", "proactive",
"moa", "journey", "learning", "memory-graph",
),
}
# Also extract subcommands hinted in args_hint via pipe-separated patterns
# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
# NOTE: If a command already has explicit subcommands, this fallback is skipped.
+101
View File
@@ -0,0 +1,101 @@
"""Tests for /help declutter + Ctrl+P command palette (C-04 / C-05)."""
from types import SimpleNamespace
from cli import HermesCLI
from hermes_cli.commands import HELP_SESSION_SUBGROUPS
def _bare_cli():
cli = HermesCLI.__new__(HermesCLI)
return cli
class TestHelpSubgroups:
def test_session_subgroups_defined(self):
# The oversized Session category is split into readable sub-headers.
assert "Context" in HELP_SESSION_SUBGROUPS
assert "Background & Automation" in HELP_SESSION_SUBGROUPS
# Representative members land in the right buckets.
assert "compress" in HELP_SESSION_SUBGROUPS["Context"]
assert "goal" in HELP_SESSION_SUBGROUPS["Background & Automation"]
class TestCommandPaletteFilter:
def _state(self, entries, query):
cli = _bare_cli()
cli._command_palette_state = {
"entries": entries, "filter": query, "selected": 0, "_scroll_offset": 0,
}
return cli
ENTRIES = [
("/model", "Model", "Switch model"),
("/compress", "Context", "Compress the conversation"),
("/goal", "Background", "Set an autonomous goal"),
("/status", "Context", "Show session status"),
]
def test_empty_query_returns_all(self):
cli = self._state(self.ENTRIES, "")
assert cli._command_palette_visible_entries() == self.ENTRIES
def test_filter_by_command_name(self):
cli = self._state(self.ENTRIES, "model")
rows = cli._command_palette_visible_entries()
assert [r[0] for r in rows] == ["/model"]
def test_filter_by_description(self):
cli = self._state(self.ENTRIES, "autonomous")
rows = cli._command_palette_visible_entries()
assert [r[0] for r in rows] == ["/goal"]
def test_subsequence_match(self):
# "cmp" is a subsequence of "/compress"
cli = self._state(self.ENTRIES, "cmprs")
rows = cli._command_palette_visible_entries()
assert "/compress" in [r[0] for r in rows]
def test_no_match(self):
cli = self._state(self.ENTRIES, "zzznope")
assert cli._command_palette_visible_entries() == []
def test_command_name_matches_rank_above_description(self):
# "status" matches /status by name (rank<=2). A row that only matches
# in its description must rank strictly after any name match.
entries = [
("/goal", "Background", "check the status of things"), # desc-only match
("/status", "Context", "Show session status"), # name match
]
cli = self._state(entries, "status")
rows = cli._command_palette_visible_entries()
assert rows[0][0] == "/status" # name match ranks first
def test_query_does_not_overmatch_via_description(self):
# A short query must not match a row solely because its letters appear
# as a scattered subsequence of the description (the "steer" bug).
entries = [
("/new", "Session", "Start a totally separate resettable session"),
]
cli = self._state(entries, "steer")
# "steer" is NOT a subsequence of "/new" and not a substring of the
# description, so the row is dropped.
assert cli._command_palette_visible_entries() == []
class TestCommandPaletteSelectionInserts:
def test_selection_prefills_exact_command(self):
cli = _bare_cli()
cli._command_palette_state = {
"entries": [("/model", "Model", "Switch model")],
"filter": "", "selected": 0, "_scroll_offset": 0,
}
# Fake prompt_toolkit app + buffer.
buf = SimpleNamespace(text="", cursor_position=0)
cli._app = SimpleNamespace(current_buffer=buf)
cli._invalidate = lambda **k: None
cli._restore_modal_input_snapshot = lambda: None
cli._handle_command_palette_selection()
# Exact command string inserted (with trailing space), never executed.
assert buf.text == "/model "
assert cli._command_palette_state is None # palette closed
+2 -1
View File
@@ -123,7 +123,8 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| Command | Description |
|---------|-------------|
| `/help` | Show this help message |
| `/help` | Show available commands, grouped by category. Core commands are shown by default with skill commands collapsed to a one-line count; `/help skills` lists all skill commands, and `/help <text>` filters commands (and matching skills) by substring. |
| `/palette` | Open the fuzzy command palette (also **Ctrl+P**) — type to filter all commands + skills, ↑/↓ to move, Enter to insert the selected command into the composer (never auto-runs), Esc to cancel. Matching is ranked by command name first, so a short query stays precise. |
| `/version` | Show Hermes Agent version, build, and environment info. |
| `/whoami` | Show your slash command access level (admin / user). |
| `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. |