mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
* 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>
* 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>
* 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>
* desktop: registry-driven slash commands with first-class pickers
Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table
that drives popover suggestions, per-type composer pills, and execution.
- /resume, /sessions, /switch: inline session completions (like /skin) plus
a "Browse all sessions…" entry that opens a dedicated session picker overlay
- /handoff: inline platform completion + handoff.request/handoff.state
gateway bridge so desktop reaches CLI parity
- colored per-type pills (command/skill/theme) in the composer
- strip ANSI and fix width/alignment of slash output in the chat panel
* desktop: fold repeated slash session/output boilerplate into one helper
runExec, /title, /help and the unavailable case each re-derived the same
ensure-session → bail-with-notify → build-renderSlashOutput dance.
withSlashOutput() returns {sessionId, render} or null, so each handler is
a two-line resolve instead of an eight-line preamble.
* desktop: keep backend meta on slash arg completions
Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff
<platform>) were having their meta overwritten with the parent command's
registry description: desktopSlashDescription("/personality none") canonicalizes
back to /personality and returns its blurb. Skip the lookup for arg rows so the
backend's own display_meta ("clear personality overlay", etc.) survives.
* cli: list real personalities in /personality completion
_personality_completions resolved load_config().agent.personalities — but that
schema has no agent.personalities key, so completion always returned just
`none` even though the runtime (load_cli_config().agent.personalities) ships a
dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the
command actually applies, so `/personality ` surfaces the real options.
* desktop: expand bare arg-commands to their options on pick
Picking a command like /personality from the slash popover committed it
immediately instead of advancing to its argument list. Mark arg-taking
commands (/skin, /resume, /handoff, /personality, /tools) in the registry
and, when one is picked bare, insert "/cmd " as plain text and re-open the
popover on its inline options — mirroring typing "/cmd " by hand. Arg picks
(serialized text already contains a space) still commit a single pill.
Also realign trigger-popover loading test with the redesigned popover (the
/help empty-state hint shows when resolved, not while the spinner is up);
the merge from main reintroduced the pre-redesign expectation.
* tui_gateway: fold session-db close into a context manager
Both handoff RPCs repeated the same `db, close_db = _session_db_handle()`
+ `finally: if close_db: db.close()` dance. Turn the helper into a
`_session_db` contextmanager that owns the close, so callers just
`with _session_db(session) as db:`.
* desktop: unblock handoff retries and exact resume ids
Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
104 lines
3 KiB
TypeScript
104 lines
3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text-utils'
|
|
|
|
describe('detectTrigger', () => {
|
|
it('detects a bare slash trigger with an empty query', () => {
|
|
expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 })
|
|
})
|
|
|
|
it('detects a slash command query', () => {
|
|
expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 })
|
|
})
|
|
|
|
it('detects a bare at-mention trigger with an empty query', () => {
|
|
expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 })
|
|
})
|
|
|
|
it('detects an at-mention query', () => {
|
|
expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 })
|
|
})
|
|
|
|
it('returns null for plain text', () => {
|
|
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', () => {
|
|
it('dedupes the same image exposed on both items and files', () => {
|
|
const image = new File([new Uint8Array([1, 2, 3])], 'paste.png', {
|
|
type: 'image/png',
|
|
lastModified: 1_700_000_000_000
|
|
})
|
|
|
|
const clipboard = {
|
|
files: {
|
|
length: 1,
|
|
item: (index: number) => (index === 0 ? image : null)
|
|
},
|
|
getData: () => '',
|
|
items: [
|
|
{
|
|
kind: 'file',
|
|
type: 'image/png',
|
|
getAsFile: () => image
|
|
}
|
|
]
|
|
} as unknown as DataTransfer
|
|
|
|
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
|
|
})
|
|
|
|
it('falls back to files when items has no image', () => {
|
|
const image = new File([new Uint8Array([4, 5])], 'shot.jpg', {
|
|
type: 'image/jpeg',
|
|
lastModified: 1_700_000_000_001
|
|
})
|
|
|
|
const clipboard = {
|
|
files: {
|
|
length: 1,
|
|
item: (index: number) => (index === 0 ? image : null)
|
|
},
|
|
getData: () => '',
|
|
items: []
|
|
} as unknown as DataTransfer
|
|
|
|
expect(extractClipboardImageBlobs(clipboard)).toEqual([image])
|
|
})
|
|
})
|
|
|
|
describe('blobDedupeKey', () => {
|
|
it('uses file metadata for File blobs', () => {
|
|
const file = new File([], 'a.png', { type: 'image/png', lastModified: 42 })
|
|
|
|
expect(blobDedupeKey(file)).toBe('file:a.png:0:image/png:42')
|
|
})
|
|
})
|