Compare commits

...

3 Commits

Author SHA1 Message Date
Brooklyn Nicholson
1a926a45a7 cli: complete toolset names after /tools enable|disable
SlashCommandCompleter previously only auto-derived the first subcommand level
from args_hint, so `/tools enable <tab>` yielded nothing — the user had to
remember every toolset key (web, file, spotify, …) and every MCP server prefix.

Add `_tools_completions` that handles both stages: subcommand (list|disable|enable)
and tool name. Filter by current enable state so `/tools enable <tab>` only
offers disabled toolsets and `/tools disable <tab>` only offers enabled ones —
no point suggesting a no-op. MCP server prefixes (server:) come from the
saved mcp_servers config; per-tool completion under a server would require
runtime MCP introspection and is left as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:03:01 -05:00
Brooklyn Nicholson
b2968bbd70 desktop: keep slash popover live while typing args
The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment
the user typed a space after a slash command, so the popover never showed arg
completions for `/personality`, `/tools`, etc. — even though the backend's
`complete.slash` already returns them with a `replace_from` indicator.

Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@`
keeps the strict no-space behavior. Restrict the slash command name to
`[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger
the popover.

Rewrite arg-completion items in useSlashCompletions to insert the full
`/personality alice` token instead of stranding `/alice`: when `replace_from`
is past the command base, prepend the existing prefix to each item's text so
the chip serializer produces a coherent replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:28:10 -05:00
Brooklyn Nicholson
cde41f3140 desktop: surface /tools, /save, /personality and fix /help skill count
Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of
ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via
the existing slash.exec → command.dispatch fallback. The backend gateway already
accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the
skill list), so no backend change is required.

Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs.
Previously the /help footer echoed the unfiltered backend total — e.g. "60
skill commands available" while only ~29 actually appeared in the rendered
list, because the desktop hides terminal-only, picker-owned, and advanced
commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 18:59:02 -05:00
7 changed files with 313 additions and 11 deletions

View File

@@ -67,10 +67,30 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }):
return { items, query } return { items, query }
} }
const result = await gateway.request<{ items?: CompletionEntry[] }>('complete.slash', { text }) const result = await gateway.request<{ items?: CompletionEntry[]; replace_from?: number }>(
'complete.slash',
{ text }
)
// Arg-completion items (replace_from > 1) carry just the arg stub —
// e.g. complete.slash returns `{text: "alice"}` for `/personality alic`
// with replace_from = 14. Rewrite those entries so the popover inserts
// the full `/personality alice` token instead of stranding `/alice`.
const replaceFrom = typeof result.replace_from === 'number' ? result.replace_from : 1
const isArgCompletion = replaceFrom > 1
const prefix = isArgCompletion ? text.slice(0, replaceFrom) : ''
const items = (result.items ?? []) const items = (result.items ?? [])
.filter(item => isDesktopSlashSuggestion(item.text)) .map(item => {
if (!isArgCompletion) {
return item
}
const argText = typeof item.text === 'string' ? item.text : ''
return { ...item, text: `${prefix}${argText}` }
})
.filter(item => isArgCompletion || isDesktopSlashSuggestion(item.text))
.map(item => ({ .map(item => ({
...item, ...item,
meta: desktopSlashDescription(item.text, textValue(item.meta)) meta: desktopSlashDescription(item.text, textValue(item.meta))

View File

@@ -22,6 +22,33 @@ describe('detectTrigger', () => {
it('returns null for plain text', () => { it('returns null for plain text', () => {
expect(detectTrigger('hello there')).toBeNull() expect(detectTrigger('hello there')).toBeNull()
}) })
it('keeps the slash trigger live while typing args', () => {
expect(detectTrigger('/personality ')).toEqual({
kind: '/',
query: 'personality ',
tokenLength: 13
})
expect(detectTrigger('/personality alic')).toEqual({
kind: '/',
query: 'personality alic',
tokenLength: 17
})
expect(detectTrigger('/tools enable foo')).toEqual({
kind: '/',
query: 'tools enable foo',
tokenLength: 17
})
})
it('does not treat file-style paths as slash triggers', () => {
expect(detectTrigger('src/foo/bar')).toBeNull()
expect(detectTrigger('/path/to/file')).toBeNull()
})
it('still anchors at-mention triggers strictly at the token edge', () => {
expect(detectTrigger('@file:path with space')).toBeNull()
})
}) })
describe('extractClipboardImageBlobs', () => { describe('extractClipboardImageBlobs', () => {

View File

@@ -6,7 +6,13 @@ export interface TriggerState {
tokenLength: number tokenLength: number
} }
const TRIGGER_RE = /(?:^|[\s])([@/])([^\s@/]*)$/ // `@` triggers stop at the first whitespace — `@file:path` and `@diff` are
// single tokens. `/` triggers keep going so the popover stays live while the
// user types args (`/personality alic` → arg completer suggests `alice`).
// Restricting the slash command name to `[a-zA-Z][\w-]*` avoids matching file
// paths like `src/foo/bar`.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@/]*)$/
const SLASH_TRIGGER_RE = /(?:^|[\s])(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */ /** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string { export function blobDedupeKey(blob: Blob): string {
@@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
} }
export function detectTrigger(textBefore: string): TriggerState | null { export function detectTrigger(textBefore: string): TriggerState | null {
const match = TRIGGER_RE.exec(textBefore) const slash = SLASH_TRIGGER_RE.exec(textBefore)
if (!match) { if (slash) {
return null return { kind: '/', query: slash[2], tokenLength: 1 + slash[2].length }
} }
return { kind: match[1] as '@' | '/', query: match[2], tokenLength: 1 + match[2].length } const at = AT_TRIGGER_RE.exec(textBefore)
if (at) {
return { kind: '@', query: at[2], tokenLength: 1 + at[2].length }
}
return null
} }

View File

@@ -38,6 +38,18 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/curator')).toBe(false) expect(isDesktopSlashSuggestion('/curator')).toBe(false)
}) })
it('surfaces /tools, /save, and /personality on the desktop', () => {
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
expect(isDesktopSlashSuggestion('/save')).toBe(true)
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
expect(isDesktopSlashCommand('/tools')).toBe(true)
expect(isDesktopSlashCommand('/save')).toBe(true)
expect(isDesktopSlashCommand('/personality')).toBe(true)
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('allows aliases to execute without cluttering the popover', () => { it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false) expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true) expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -74,6 +86,24 @@ describe('desktop slash command curation', () => {
['/new', 'Start a new desktop chat'], ['/new', 'Start a new desktop chat'],
['/ship-it', 'Run release checklist'] ['/ship-it', 'Run release checklist']
]) ])
// skill_count is recomputed from the filtered output (only /ship-it is an
// extension command — /new is a built-in) so the /help footer matches what
// the user actually sees rather than echoing the unfiltered backend total.
expect(filtered.skill_count).toBe(1)
})
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
const filtered = filterDesktopCommandsCatalog({
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen'],
['/gif-search', 'Search for a gif'],
['/ship-it', 'Run release checklist']
],
skill_count: 12
})
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
expect(filtered.skill_count).toBe(2) expect(filtered.skill_count).toBe(2)
}) })

View File

@@ -31,16 +31,19 @@ const DESKTOP_COMMAND_META = [
['/goal', 'Manage the standing goal for this session'], ['/goal', 'Manage the standing goal for this session'],
['/help', 'Show desktop slash commands'], ['/help', 'Show desktop slash commands'],
['/new', 'Start a new desktop chat'], ['/new', 'Start a new desktop chat'],
['/personality', 'Switch personality for this session'],
['/profile', 'Switch the active Hermes profile'], ['/profile', 'Switch the active Hermes profile'],
['/queue', 'Queue a prompt for the next turn'], ['/queue', 'Queue a prompt for the next turn'],
['/resume', 'Resume a saved session'], ['/resume', 'Resume a saved session'],
['/retry', 'Retry the last user message'], ['/retry', 'Retry the last user message'],
['/rollback', 'List or restore filesystem checkpoints'], ['/rollback', 'List or restore filesystem checkpoints'],
['/save', 'Save the current transcript to JSON'],
['/skin', 'Switch desktop theme or cycle to the next one'], ['/skin', 'Switch desktop theme or cycle to the next one'],
['/status', 'Show current session status'], ['/status', 'Show current session status'],
['/steer', 'Steer the current run after the next tool call'], ['/steer', 'Steer the current run after the next tool call'],
['/stop', 'Stop running background processes'], ['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'], ['/title', 'Rename the current session'],
['/tools', 'List or toggle tools available to the agent'],
['/undo', 'Remove the last user/assistant exchange'], ['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'], ['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'], ['/version', 'Show Hermes Agent version'],
@@ -90,7 +93,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
'/redraw', '/redraw',
'/reload', '/reload',
'/restart', '/restart',
'/save',
'/sb', '/sb',
'/set-home', '/set-home',
'/sethome', '/sethome',
@@ -98,7 +100,6 @@ const TERMINAL_ONLY_COMMANDS = new Set([
'/snapshot', '/snapshot',
'/statusbar', '/statusbar',
'/toolsets', '/toolsets',
'/tools',
'/update', '/update',
'/verbose' '/verbose'
]) ])
@@ -112,7 +113,6 @@ const ADVANCED_COMMANDS = new Set([
'/fast', '/fast',
'/insights', '/insights',
'/kanban', '/kanban',
'/personality',
'/reasoning', '/reasoning',
'/reload-mcp', '/reload-mcp',
'/reload-skills', '/reload-skills',
@@ -274,10 +274,32 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
?.filter(([command]) => isDesktopSlashSuggestion(command)) ?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string]) .map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
// Recount skill commands from the filtered output so /help's footer reflects
// what the user actually sees. Backend's skill_count includes commands the
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
// like "60 skill commands available" while only ~29 appear in the list.
const filteredCommands = new Set<string>()
for (const section of categories ?? []) {
for (const [command] of section.pairs) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
}
for (const [command] of pairs ?? []) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
let skillCount = 0
for (const command of filteredCommands) {
if (isDesktopSlashExtensionCommand(command)) {
skillCount += 1
}
}
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
return { return {
...catalog, ...catalog,
...(categories ? { categories } : {}), ...(categories ? { categories } : {}),
...(pairs ? { pairs } : {}) ...(pairs ? { pairs } : {}),
...(hasSkillCount ? { skill_count: skillCount } : {})
} }
} }

View File

@@ -1538,6 +1538,91 @@ class SlashCommandCompleter(Completer):
except Exception: except Exception:
pass pass
@staticmethod
def _tools_completions(sub_text: str, sub_lower: str):
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
keys and MCP server prefixes, filtered by current enable state so the
user only sees actionable options).
"""
SUBS = ("list", "disable", "enable")
parts = sub_text.split()
trailing_space = sub_text.endswith(" ")
# Subcommand stage: zero words typed, or completing the first word.
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
partial = sub_text if not trailing_space else ""
for sub in SUBS:
if sub.startswith(partial.lower()) and sub != partial.lower():
yield Completion(sub, start_position=-len(partial), display=sub)
return
subcommand = parts[0].lower()
if subcommand not in ("enable", "disable"):
return
partial = "" if trailing_space else parts[-1]
partial_lower = partial.lower()
already = set(parts[1:] if trailing_space else parts[1:-1])
try:
from hermes_cli.config import load_config
from hermes_cli.tools_config import (
CONFIGURABLE_TOOLSETS,
_get_platform_tools,
_get_plugin_toolset_keys,
)
config = load_config()
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta=label,
)
for ts_key in sorted(_get_plugin_toolset_keys()):
if ts_key in already or not ts_key.startswith(partial_lower):
continue
is_on = ts_key in enabled
if subcommand == "enable" and is_on:
continue
if subcommand == "disable" and not is_on:
continue
yield Completion(
ts_key,
start_position=-len(partial),
display=ts_key,
display_meta="plugin toolset",
)
mcp_servers = config.get("mcp_servers") or {}
if isinstance(mcp_servers, dict):
for server in sorted(mcp_servers):
prefix = f"{server}:"
if prefix in already or not prefix.startswith(partial_lower):
continue
yield Completion(
prefix,
start_position=-len(partial),
display=prefix,
display_meta=f"MCP server '{server}'",
)
except Exception:
return
@staticmethod @staticmethod
def _personality_completions(sub_text: str, sub_lower: str): def _personality_completions(sub_text: str, sub_lower: str):
"""Yield completions for /personality from configured personalities.""" """Yield completions for /personality from configured personalities."""
@@ -1596,6 +1681,13 @@ class SlashCommandCompleter(Completer):
yield from self._personality_completions(sub_text, sub_lower) yield from self._personality_completions(sub_text, sub_lower)
return return
# /tools needs multi-word completion (subcommand + toolset name)
# so it handles both stages itself, bypassing the single-word
# SUBCOMMANDS branch below.
if base_cmd == "/tools":
yield from self._tools_completions(sub_text, sub_lower)
return
# Static subcommand completions # Static subcommand completions
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
for sub in SUBCOMMANDS[base_cmd]: for sub in SUBCOMMANDS[base_cmd]:

View File

@@ -687,6 +687,105 @@ class TestSubcommandCompletion:
completions = _completions(SlashCommandCompleter(), "/help ") completions = _completions(SlashCommandCompleter(), "/help ")
assert completions == [] assert completions == []
def test_tools_subcommand_completion(self):
"""`/tools ` should suggest list, disable, enable."""
completions = _completions(SlashCommandCompleter(), "/tools ")
texts = {c.text for c in completions}
assert texts == {"list", "disable", "enable"}
def test_tools_subcommand_prefix_filters(self):
completions = _completions(SlashCommandCompleter(), "/tools en")
texts = {c.text for c in completions}
assert texts == {"enable"}
def test_tools_enable_completes_toolset_names(self, monkeypatch):
"""`/tools enable ` should suggest currently-disabled toolsets."""
from hermes_cli import commands as commands_mod
# `web` is enabled, `spotify` is disabled — enabling should only offer
# the disabled ones.
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable ")
texts = {c.text for c in completions}
# Should include disabled toolsets, exclude already-enabled ones.
assert "web" not in texts
assert "file" not in texts
assert "spotify" in texts
def test_tools_disable_completes_enabled_toolsets_only(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: {"web", "file"},
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools disable ")
texts = {c.text for c in completions}
# Should include enabled toolsets, exclude disabled ones.
assert texts == {"web", "file"}
def test_tools_enable_partial_filters(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable sp")
texts = {c.text for c in completions}
assert texts == {"spotify"}
def test_tools_enable_skips_already_listed(self, monkeypatch):
"""If the user already typed a name, don't suggest it again."""
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable spotify ")
texts = {c.text for c in completions}
assert "spotify" not in texts
def test_tools_suggests_mcp_server_prefixes(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.tools_config._get_platform_tools",
lambda *_a, **_k: set(),
)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {}, "linear": {}}},
)
monkeypatch.setattr(
"hermes_cli.tools_config._get_plugin_toolset_keys",
lambda: set(),
)
completions = _completions(SlashCommandCompleter(), "/tools enable git")
texts = {c.text for c in completions}
assert "github:" in texts
# ── Ghost text (SlashCommandAutoSuggest) ──────────────────────────────── # ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────