From 05b4ab0ceb9c2947774d00a71b40233f27c5c312 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:26:55 -0700 Subject: [PATCH] feat(cli): declutter /help + Ctrl+P command palette (C-04/C-05) --- cli.py | 365 +++++++++++++++++++++-- hermes_cli/commands.py | 24 +- tests/cli/test_command_palette.py | 101 +++++++ website/docs/reference/slash-commands.md | 3 +- 4 files changed, 471 insertions(+), 22 deletions(-) create mode 100644 tests/cli/test_command_palette.py diff --git a/cli.py b/cli.py index 2c4715d86c..e827d8c5f1 100644 --- a/cli.py +++ b/cli.py @@ -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 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 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, diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 56624a1691..04deecd0ad 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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 filters)", "Info", busy_policy="dispatch", + execute="gateway_help", args_hint="[skills|]"), + 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. diff --git a/tests/cli/test_command_palette.py b/tests/cli/test_command_palette.py new file mode 100644 index 0000000000..fa0ca9aa84 --- /dev/null +++ b/tests/cli/test_command_palette.py @@ -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 diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index f40cb0417b..fb9fc5b2b5 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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 ` 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. |