mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-07 13:02:07 +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>
186 lines
5.3 KiB
TypeScript
186 lines
5.3 KiB
TypeScript
// Minimal ANSI SGR parser for rendering terminal output inside chat tool
|
|
// cards. Only handles the SGR codes that show up in practice (color, bold,
|
|
// reset); cursor motions and other CSI sequences are dropped silently.
|
|
//
|
|
// Returns a flat array of styled segments so callers can render them as
|
|
// React spans without each consumer having to re-implement the parser.
|
|
|
|
export interface AnsiSegment {
|
|
bold: boolean
|
|
/** Tailwind text-color class or null for the default foreground. */
|
|
fg: AnsiColor | null
|
|
text: string
|
|
}
|
|
|
|
export type AnsiColor =
|
|
| 'black'
|
|
| 'red'
|
|
| 'green'
|
|
| 'yellow'
|
|
| 'blue'
|
|
| 'magenta'
|
|
| 'cyan'
|
|
| 'white'
|
|
| 'bright-black'
|
|
| 'bright-red'
|
|
| 'bright-green'
|
|
| 'bright-yellow'
|
|
| 'bright-blue'
|
|
| 'bright-magenta'
|
|
| 'bright-cyan'
|
|
| 'bright-white'
|
|
|
|
const FG_BY_CODE: Record<number, AnsiColor> = {
|
|
30: 'black',
|
|
31: 'red',
|
|
32: 'green',
|
|
33: 'yellow',
|
|
34: 'blue',
|
|
35: 'magenta',
|
|
36: 'cyan',
|
|
37: 'white',
|
|
90: 'bright-black',
|
|
91: 'bright-red',
|
|
92: 'bright-green',
|
|
93: 'bright-yellow',
|
|
94: 'bright-blue',
|
|
95: 'bright-magenta',
|
|
96: 'bright-cyan',
|
|
97: 'bright-white'
|
|
}
|
|
|
|
// CSI = ESC '[' params 'final'. We only care about SGR (final == 'm'); other
|
|
// final bytes are matched and consumed so they don't leak into the rendered
|
|
// text. Range covers the common CSI command set (A-Z / a-z / @).
|
|
// eslint-disable-next-line no-control-regex
|
|
const CSI_RE = /\x1b\[([\d;]*)([\x40-\x7e])/g
|
|
// Other escape sequences (single-char OSC/SS3/etc.) — strip silently.
|
|
// eslint-disable-next-line no-control-regex
|
|
const OTHER_ESCAPE_RE = /\x1b[@-Z\\-_]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
|
|
|
|
export function parseAnsi(input: string): AnsiSegment[] {
|
|
if (!input) {
|
|
return []
|
|
}
|
|
|
|
// Strip non-CSI escapes upfront — none of them carry text we want to keep
|
|
// and CSI_RE wouldn't match them.
|
|
const cleaned = input.replace(OTHER_ESCAPE_RE, '')
|
|
|
|
const segments: AnsiSegment[] = []
|
|
let cursor = 0
|
|
let bold = false
|
|
let fg: AnsiColor | null = null
|
|
|
|
const pushText = (text: string) => {
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
const last = segments.at(-1)
|
|
|
|
if (last && last.bold === bold && last.fg === fg) {
|
|
last.text += text
|
|
|
|
return
|
|
}
|
|
|
|
segments.push({ bold, fg, text })
|
|
}
|
|
|
|
CSI_RE.lastIndex = 0
|
|
let match: RegExpExecArray | null
|
|
|
|
while ((match = CSI_RE.exec(cleaned)) !== null) {
|
|
const start = match.index
|
|
|
|
if (start > cursor) {
|
|
pushText(cleaned.slice(cursor, start))
|
|
}
|
|
|
|
if (match[2] === 'm') {
|
|
const codes = match[1]
|
|
.split(';')
|
|
.map(part => (part === '' ? 0 : Number(part)))
|
|
.filter(value => Number.isFinite(value))
|
|
|
|
for (let i = 0; i < codes.length; i += 1) {
|
|
const code = codes[i]
|
|
|
|
if (code === 0) {
|
|
bold = false
|
|
fg = null
|
|
} else if (code === 1) {
|
|
bold = true
|
|
} else if (code === 22) {
|
|
bold = false
|
|
} else if (code === 39) {
|
|
fg = null
|
|
} else if (code in FG_BY_CODE) {
|
|
fg = FG_BY_CODE[code]
|
|
} else if (code === 38) {
|
|
// 256-color / truecolor — skip the trailing args we don't render.
|
|
if (codes[i + 1] === 5) {
|
|
i += 2
|
|
} else if (codes[i + 1] === 2) {
|
|
i += 4
|
|
}
|
|
}
|
|
// Background colors (40-47, 100-107) and effects we don't render are
|
|
// intentionally ignored — the segment keeps the prior bold/fg state.
|
|
}
|
|
}
|
|
|
|
cursor = CSI_RE.lastIndex
|
|
}
|
|
|
|
if (cursor < cleaned.length) {
|
|
pushText(cleaned.slice(cursor))
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
const TAILWIND_BY_COLOR: Record<AnsiColor, string> = {
|
|
// Tuned for legibility against the muted bg-(--ui-bg-tertiary) surface used
|
|
// in tool cards. We don't paint pure ANSI colors (#000, #fff) because they
|
|
// disappear into the surface.
|
|
black: 'text-zinc-700 dark:text-zinc-300',
|
|
red: 'text-red-700 dark:text-red-300',
|
|
green: 'text-emerald-700 dark:text-emerald-300',
|
|
yellow: 'text-amber-700 dark:text-amber-300',
|
|
blue: 'text-blue-700 dark:text-blue-300',
|
|
magenta: 'text-fuchsia-700 dark:text-fuchsia-300',
|
|
cyan: 'text-cyan-700 dark:text-cyan-300',
|
|
white: 'text-zinc-600 dark:text-zinc-200',
|
|
'bright-black': 'text-zinc-500 dark:text-zinc-400',
|
|
'bright-red': 'text-rose-600 dark:text-rose-300',
|
|
'bright-green': 'text-emerald-600 dark:text-emerald-200',
|
|
'bright-yellow': 'text-amber-600 dark:text-amber-200',
|
|
'bright-blue': 'text-sky-600 dark:text-sky-300',
|
|
'bright-magenta': 'text-pink-600 dark:text-pink-300',
|
|
'bright-cyan': 'text-teal-600 dark:text-teal-200',
|
|
'bright-white': 'text-zinc-500 dark:text-zinc-100'
|
|
}
|
|
|
|
export function ansiColorClass(color: AnsiColor): string {
|
|
return TAILWIND_BY_COLOR[color]
|
|
}
|
|
|
|
/** Returns true if the input contains at least one CSI sequence. Cheap check
|
|
* so callers can skip the parser for plain-ASCII output. */
|
|
export function hasAnsiCodes(input: string): boolean {
|
|
// eslint-disable-next-line no-control-regex
|
|
return /\x1b\[/.test(input)
|
|
}
|
|
|
|
/** Remove all ANSI escape sequences, returning plain text. Use when output is
|
|
* rendered as text (e.g. chat system messages) rather than styled segments —
|
|
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
|
|
export function stripAnsi(input: string): string {
|
|
if (!input) {
|
|
return input
|
|
}
|
|
|
|
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
|
|
}
|