From 3ffbdfbcc0dce5b859411666677e0f86d583dda0 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 10 Jun 2026 20:49:24 -0500 Subject: [PATCH] desktop: registry-driven slash commands + first-class /resume & /handoff (#42351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * cli: complete toolset names after /tools enable|disable SlashCommandCompleter previously only auto-derived the first subcommand level from args_hint, so `/tools enable ` 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 ` only offers disabled toolsets and `/tools disable ` 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) * 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 , /tools enable , /handoff ) 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) --- .../app/chat/composer/completion-drawer.tsx | 31 +- .../hooks/use-live-completion-adapter.ts | 7 + .../composer/hooks/use-slash-completions.ts | 145 +++- apps/desktop/src/app/chat/composer/index.tsx | 106 ++- .../src/app/chat/composer/rich-editor.ts | 23 +- .../app/chat/composer/skin-slash-popover.tsx | 61 -- .../src/app/chat/composer/text-utils.test.ts | 27 + .../src/app/chat/composer/text-utils.ts | 22 +- .../chat/composer/trigger-popover.test.tsx | 10 +- .../src/app/chat/composer/trigger-popover.tsx | 120 +++- apps/desktop/src/app/desktop-controller.tsx | 3 + .../src/app/session-picker-overlay.tsx | 32 + .../session/hooks/use-prompt-actions.test.tsx | 81 ++- .../app/session/hooks/use-prompt-actions.ts | 631 ++++++++++++------ apps/desktop/src/app/types.ts | 20 + .../assistant-ui/directive-text.tsx | 44 +- .../src/components/assistant-ui/thread.tsx | 28 +- .../desktop/src/components/session-picker.tsx | 108 +++ apps/desktop/src/i18n/en.ts | 9 +- apps/desktop/src/i18n/ja.ts | 9 +- apps/desktop/src/i18n/types.ts | 7 + apps/desktop/src/i18n/zh-hant.ts | 9 +- apps/desktop/src/i18n/zh.ts | 9 +- apps/desktop/src/lib/ansi.ts | 11 + .../src/lib/desktop-slash-commands.test.ts | 56 +- .../desktop/src/lib/desktop-slash-commands.ts | 384 +++++++---- apps/desktop/src/store/session.ts | 2 + hermes_cli/commands.py | 143 +++- tests/hermes_cli/test_commands.py | 163 +++++ tests/test_tui_gateway_server.py | 41 ++ tui_gateway/server.py | 168 +++++ 31 files changed, 1973 insertions(+), 537 deletions(-) delete mode 100644 apps/desktop/src/app/chat/composer/skin-slash-popover.tsx create mode 100644 apps/desktop/src/app/session-picker-overlay.tsx create mode 100644 apps/desktop/src/components/session-picker.tsx diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 8b23c54f87..d7738cb82a 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -3,32 +3,25 @@ import { ComposerPrimitive } from '@assistant-ui/react' import type { ReactNode } from 'react' export const COMPLETION_DRAWER_CLASS = [ - 'absolute bottom-[calc(100%+0.25rem)] left-0 z-50', - 'w-60 max-w-[calc(100vw-2rem)]', - 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-lg border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-md', + 'absolute bottom-[calc(100%+0.375rem)] left-0 z-50', + 'w-80 max-w-[calc(100vw-2rem)]', + 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', + 'rounded-xl border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-lg', 'backdrop-blur-md' ].join(' ') export const COMPLETION_DRAWER_BELOW_CLASS = [ - 'absolute left-0 top-[calc(100%+0.25rem)] z-50', - 'w-60 max-w-[calc(100vw-2rem)]', - 'max-h-[min(23rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', - 'rounded-lg border border-(--ui-stroke-secondary)', - 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)]', - 'p-1 text-xs text-popover-foreground shadow-md', + 'absolute left-0 top-[calc(100%+0.375rem)] z-50', + 'w-80 max-w-[calc(100vw-2rem)]', + 'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain', + 'rounded-xl border border-(--ui-stroke-secondary)', + 'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]', + 'p-1 text-xs text-popover-foreground shadow-lg', 'backdrop-blur-md' ].join(' ') -export const COMPLETION_DRAWER_ROW_CLASS = [ - 'relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1', - 'w-full min-w-0 text-left text-xs outline-hidden transition-colors', - 'hover:bg-(--ui-bg-tertiary)', - 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground' -].join(' ') - export function ComposerCompletionDrawer({ adapter, ariaLabel, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index fbeca7d59e..6da699b602 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -5,6 +5,13 @@ export interface CompletionEntry { text: string display?: unknown meta?: unknown + /** Optional section label (e.g. "Commands", "Skills"). The popover renders a + * header whenever this changes between consecutive items, so the fetcher must + * emit entries already grouped contiguously. */ + group?: string + /** Optional completion-action id. When set, picking the item runs that action + * (e.g. opening an overlay) instead of inserting a chip + waiting for submit. */ + action?: string } export interface CompletionPayload { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index f334415809..b0bac82825 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -2,12 +2,17 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u import { useCallback } from 'react' import type { HermesGateway } from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' import { type CommandsCatalogLike, + desktopSkinSlashCompletions, desktopSlashDescription, + type DesktopThemeCommandOption, filterDesktopCommandsCatalog, + isDesktopSlashExtensionCommand, isDesktopSlashSuggestion } from '@/lib/desktop-slash-commands' +import { $sessions } from '@/store/session' import type { CompletionEntry, CompletionPayload } from './use-live-completion-adapter' import { useLiveCompletionAdapter } from './use-live-completion-adapter' @@ -16,7 +21,10 @@ interface SlashItemMetadata extends Record { command: string display: string meta: string + group: string rawText: string + /** Completion-action id; empty for ordinary insert-a-chip completions. */ + action: string } function textValue(value: unknown, fallback = ''): string { @@ -38,12 +46,21 @@ function commandText(value: string): string { return value.startsWith('/') ? value : `/${value}` } +/** How many recent sessions to surface inline before the "Browse all…" entry. */ +const SESSION_INLINE_LIMIT = 7 + /** Live `/` completions backed by the gateway's `complete.slash` RPC. */ -export function useSlashCompletions(options: { gateway: HermesGateway | null }): { +export function useSlashCompletions(options: { + gateway: HermesGateway | null + /** Desktop theme list — `/skin` is owned client-side, so its arg completions + * come from here, not the backend (whose skin list is CLI/TUI-only). */ + skinThemes?: DesktopThemeCommandOption[] + activeSkin?: string +}): { adapter: Unstable_TriggerAdapter loading: boolean } { - const { gateway } = options + const { gateway, skinThemes, activeSkin } = options const enabled = Boolean(gateway) const fetcher = useCallback( @@ -54,34 +71,136 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }): const text = `/${query}` + // The desktop owns /skin entirely (client-side theme context). Surface its + // theme list inside this single popover instead of a bespoke one, and skip + // the backend skin completions (which describe CLI/TUI skins that don't + // apply here). Matches once we're past `/skin ` into the arg stage. + const skinArg = /^\/skin\s+(.*)$/is.exec(text) + + if (skinArg && skinThemes) { + const items = desktopSkinSlashCompletions(skinThemes, activeSkin ?? '', skinArg[1] ?? '').map(entry => ({ + text: entry.text, + display: entry.display, + meta: entry.meta, + group: 'Themes' + })) + + return { items, query } + } + + // /resume (and its aliases) completes recent sessions inline — the same + // client-side list the picker overlay shows — instead of the backend + // (whose /resume opens an interactive TUI picker we can't render here). + const sessionArg = /^\/(?:resume|sessions|switch)\s+(.*)$/is.exec(text) + + if (sessionArg) { + const needle = (sessionArg[1] ?? '').trim().toLowerCase() + + const matches = ( + needle + ? $sessions.get().filter( + session => + sessionTitle(session).toLowerCase().includes(needle) || + (session.preview ?? '').toLowerCase().includes(needle) || + session.id.toLowerCase().includes(needle) + ) + : $sessions.get() + ).slice(0, SESSION_INLINE_LIMIT) + + const items: CompletionEntry[] = matches.map(session => ({ + text: `/resume ${session.id}`, + display: sessionTitle(session), + meta: (session.preview ?? '').trim(), + group: 'Sessions' + })) + + // Trailing "more" affordance (Cursor-style): picking it opens the full + // session picker overlay directly. `text` stays a bare `/resume` so that + // submitting it (Enter) still opens the overlay if the action is skipped. + items.push({ + text: '/resume', + display: 'Browse all sessions…', + meta: '', + group: 'Sessions', + action: 'session-picker' + }) + + return { items, query } + } + try { if (!query) { const catalog = filterDesktopCommandsCatalog(await gateway.request('commands.catalog')) - const items = (catalog.pairs ?? []).map(([command, meta]) => ({ - text: command, - display: command, - meta - })) + // Prefer the categorized layout so the popover renders section headers + // (Session, Tools & Skills, ...). Fall back to the flat list when the + // backend didn't categorize. + const sections = catalog.categories?.length + ? catalog.categories + : [{ name: '', pairs: catalog.pairs ?? [] }] + + const items = sections.flatMap(section => + section.pairs.map(([command, meta]) => ({ + text: command, + display: command, + group: section.name || undefined, + meta + })) + ) 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 } + ) - const items = (result.items ?? []) - .filter(item => isDesktopSlashSuggestion(item.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 decorated = (result.items ?? []) + .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 => ({ ...item, - meta: desktopSlashDescription(item.text, textValue(item.meta)) + // Arg suggestions (e.g. `/handoff `) live under one + // header; otherwise split skills out from built-in commands. + group: isArgCompletion ? 'Options' : isDesktopSlashExtensionCommand(item.text) ? 'Skills' : 'Commands', + // Arg items carry their own meta (the personality/toolset/platform + // blurb). Only command rows get the registry description — looking + // one up for `/personality none` would clobber it with the parent + // command's text. + meta: isArgCompletion ? textValue(item.meta) : desktopSlashDescription(item.text, textValue(item.meta)) })) + // Keep each group contiguous so headers render once: Commands before + // Skills (stable within a group, preserving backend relevance order). + const groupOrder = ['Commands', 'Skills', 'Options'] + + const items = isArgCompletion + ? decorated + : [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group)) + return { items, query } } catch { return { items: [], query } } }, - [gateway] + [gateway, skinThemes, activeSkin] ) const toItem = useCallback((entry: CompletionEntry, index: number): Unstable_TriggerItem => { @@ -93,6 +212,8 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }): command, display, meta, + group: textValue(entry.group), + action: textValue(entry.action), // Provide rawText so hermesDirectiveFormatter.serialize uses the // direct-insertion path instead of the legacy @type:id fallback. // Without this, the item.id (which includes a "|index" suffix for diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d8b06a68d3..bf94883483 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -13,13 +13,14 @@ import { useState } from 'react' -import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' +import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text' import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' +import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -40,8 +41,9 @@ import { shouldAutoDrainOnSettle, updateQueuedPrompt } from '@/store/composer-queue' -import { $gatewayState, $messages } from '@/store/session' +import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { useTheme } from '@/themes' import { extractDroppedFiles, HERMES_PATHS_MIME, partitionDroppedFiles } from '../hooks/use-composer-actions' @@ -74,9 +76,9 @@ import { placeCaretEnd, refChipElement, renderComposerContents, - RICH_INPUT_SLOT + RICH_INPUT_SLOT, + slashChipElement } from './rich-editor' -import { SkinSlashPopover } from './skin-slash-popover' import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils' import { ComposerTriggerPopover } from './trigger-popover' import type { ChatBarProps } from './types' @@ -95,6 +97,30 @@ const COMPOSER_FADE_BACKGROUND = const pickPlaceholder = (pool: readonly string[]) => pool[Math.floor(Math.random() * pool.length)] +/** Completion items can carry an `action` (set in use-slash-completions) that + * runs a side effect on pick instead of inserting a chip — e.g. the session + * picker's "Browse all…" entry opens the overlay. Table-driven so new action + * items are a registry row, not a composer branch. */ +const COMPLETION_ACTIONS: Record void> = { + 'session-picker': () => setSessionPickerOpen(true) +} + +/** Map a picked `/` completion to its pill accent. Driven by the completion + * group set in use-slash-completions (Skills / Themes / Commands|Options). */ +function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind { + const group = (item.metadata as { group?: unknown } | undefined)?.group + + if (group === 'Skills') { + return 'skill' + } + + if (group === 'Themes') { + return 'theme' + } + + return 'command' +} + interface QueueEditState { attachments: ComposerAttachment[] draft: string @@ -162,8 +188,9 @@ export function ChatBar({ const narrow = useMediaQuery('(max-width: 30rem)') + const { availableThemes, themeName } = useTheme() const at = useAtCompletions({ gateway: gateway ?? null, sessionId: sessionId ?? null, cwd: cwd ?? null }) - const slash = useSlashCompletions({ gateway: gateway ?? null }) + const slash = useSlashCompletions({ activeSkin: themeName, gateway: gateway ?? null, skinThemes: availableThemes }) const stacked = expanded || narrow || tight const trimmedDraft = draft.trim() @@ -171,10 +198,12 @@ export function ChatBar({ const canSubmit = busy || hasComposerPayload const editingQueuedPrompt = queueEdit ? (queuedPrompts.find(entry => entry.id === queueEdit.entryId) ?? null) : null const busyAction = busy && hasComposerPayload ? 'queue' : 'stop' + // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). const canSteer = busy && !!onSteer && attachments.length === 0 && trimmedDraft.length > 0 && !SLASH_COMMAND_RE.test(trimmedDraft) + const showHelpHint = draft === '?' const { t } = useI18n() @@ -462,12 +491,6 @@ export function ChatBar({ }) }, []) - const selectSkinSlashCommand = (command: string) => { - draftRef.current = command - aui.composer().setText(command) - requestMainFocus() - } - const handlePaste = (event: ClipboardEvent) => { const imageBlobs = extractClipboardImageBlobs(event.clipboardData) @@ -620,16 +643,50 @@ export function ChatBar({ return } + // Action items (e.g. "Browse all sessions…") run a side effect instead of + // inserting a chip: strip the typed trigger token, then fire the action. + const completionAction = (item.metadata as { action?: unknown } | undefined)?.action + const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined + + if (runAction) { + const current = composerPlainText(editor) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + renderComposerContents(editor, prefix) + placeCaretEnd(editor) + draftRef.current = composerPlainText(editor) + aui.composer().setText(draftRef.current) + closeTrigger() + runAction() + requestMainFocus() + + return + } + const serialized = hermesDirectiveFormatter.serialize(item) const starter = serialized.endsWith(':') + + // Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit + // it — expand to its options step so the popover shows the inline list, just + // as typing `/personality ` by hand would. A serialized value with a space is + // already an arg pick (`/personality alice`), so it commits normally. + const command = (item.metadata as { command?: string } | undefined)?.command ?? '' + + const expandsToArgs = + trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} ` const directive = !starter && serialized.match(/^@([^:]+):(.+)$/) + // No pill while expanding — the bare command stays plain text until an arg + // is picked, at which point a single pill is emitted for the full command. + const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null + const keepTriggerOpen = starter || expandsToArgs const finish = () => { draftRef.current = composerPlainText(editor) aui.composer().setText(draftRef.current) requestMainFocus() - starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger() + keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger() } const sel = window.getSelection() @@ -639,7 +696,20 @@ export function ChatBar({ if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) { const current = composerPlainText(editor) - renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`) + const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength)) + + if (slashKind) { + // Two-step arg picks (e.g. `/handoff` pill already inserted, now picking + // the platform) land here because the caret sits past a contenteditable + // chip. Rebuild the prefix and re-emit a single pill for the full command. + renderComposerContents(editor, prefix) + editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' ')) + placeCaretEnd(editor) + + return finish() + } + + renderComposerContents(editor, `${prefix}${text}`) placeCaretEnd(editor) return finish() @@ -650,8 +720,13 @@ export function ChatBar({ replaceRange.setEnd(node, offset) replaceRange.deleteContents() - if (directive) { - const chip = refChipElement(directive[1], directive[2]) + const chip = slashKind + ? slashChipElement(serialized, slashKind) + : directive + ? refChipElement(directive[1], directive[2]) + : null + + if (chip) { const space = document.createTextNode(' ') const fragment = document.createDocumentFragment() fragment.append(chip, space) @@ -1515,7 +1590,6 @@ export function ChatBar({ onPick={replaceTriggerWithChip} /> )} - {activeQueueSessionKey && queuedPrompts.length > 0 && ( // Out of flow so the queue never inflates the composer's measured // height (that drives thread bottom padding → chat resizes on diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 38ab85d0f3..ea6382f9ab 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -10,7 +10,10 @@ import { DIRECTIVE_CHIP_CLASS, directiveIconElement, directiveIconSvg, - formatRefValue + formatRefValue, + slashChipClass, + type SlashChipKind, + slashIconElement } from '@/components/assistant-ui/directive-text' export const RICH_INPUT_SLOT = 'composer-rich-input' @@ -77,6 +80,24 @@ export function refChipElement(kind: string, rawValue: string, displayLabel?: st return chip } +/** A non-editable pill for a picked slash command (`/skin nous`, `/tropes`). + * `data-ref-text` carries the literal command so `composerPlainText` round-trips + * it back to the exact text that gets submitted. */ +export function slashChipElement(command: string, kind: SlashChipKind, label?: string) { + const chip = document.createElement('span') + const text = document.createElement('span') + + chip.contentEditable = 'false' + chip.dataset.refText = command + chip.dataset.slashKind = kind + chip.className = slashChipClass(kind) + text.className = 'truncate' + text.textContent = label || command + chip.append(slashIconElement(kind), text) + + return chip +} + function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: string) { const lines = text.split('\n') diff --git a/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx b/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx deleted file mode 100644 index 2bfc27e51a..0000000000 --- a/apps/desktop/src/app/chat/composer/skin-slash-popover.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useI18n } from '@/i18n' -import { desktopSkinSlashCompletions } from '@/lib/desktop-slash-commands' -import { triggerHaptic } from '@/lib/haptics' -import { useTheme } from '@/themes/context' - -import { COMPLETION_DRAWER_CLASS, COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer' - -interface SkinSlashPopoverProps { - draft: string - onSelect: (command: string) => void -} - -export function SkinSlashPopover({ draft, onSelect }: SkinSlashPopoverProps) { - const { t } = useI18n() - const c = t.composer - const { availableThemes, themeName } = useTheme() - const match = draft.match(/^\/skin\s+(\S*)$/i) - - if (!match) { - return null - } - - const items = desktopSkinSlashCompletions(availableThemes, themeName, match[1] ?? '') - - return ( -
-
- {items.length === 0 ? ( - - {c.themeTryPre} - /skin list - {c.themeTryPost} - - ) : ( - items.map(item => ( - - )) - )} -
-
- ) -} diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 5ef677f4d0..f80e6db438 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -22,6 +22,33 @@ describe('detectTrigger', () => { 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', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index e9a8fb6aae..4535d6963c 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -6,7 +6,13 @@ export interface TriggerState { 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. */ export function blobDedupeKey(blob: Blob): string { @@ -97,11 +103,17 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const match = TRIGGER_RE.exec(textBefore) + const slash = SLASH_TRIGGER_RE.exec(textBefore) - if (!match) { - return null + if (slash) { + 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 } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx index 9acc43f7f1..3aefbfee0a 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.test.tsx @@ -34,9 +34,17 @@ describe('ComposerTriggerPopover i18n', () => { }) it('renders localized loading copy for slash commands', () => { - const { container } = renderPopover('/', true) + renderPopover('/', true) + // While loading the popover shows only the spinner + loading copy — the + // `/help` empty-state hint is reserved for the resolved (not-loading) state. expect(screen.getByText('查找中…')).toBeTruthy() + }) + + it('renders the slash empty-state hint when not loading', () => { + const { container } = renderPopover('/') + + expect(screen.getByText('没有匹配项。')).toBeTruthy() expect(container.textContent).toContain('/help') }) }) diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index a09190dd6b..1099c0748b 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -1,5 +1,7 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { Fragment } from 'react' +import { BrailleSpinner } from '@/components/ui/braille-spinner' import { Codicon } from '@/components/ui/codicon' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' @@ -7,7 +9,6 @@ import { cn } from '@/lib/utils' import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, - COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty } from './completion-drawer' @@ -23,11 +24,7 @@ const AT_ICON_BY_TYPE: Record = { url: 'globe' } -function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { - if (kind === '/') { - return 'terminal' - } - +function atIcon(item: Unstable_TriggerItem) { const meta = item.metadata as { rawText?: string } | undefined const raw = meta?.rawText || item.label @@ -42,6 +39,18 @@ function completionIcon(kind: '@' | '/', item: Unstable_TriggerItem) { return AT_ICON_BY_TYPE[item.type] || AT_ICON_BY_TYPE.simple } +interface RowMeta { + display?: string + group?: string + meta?: string +} + +const ROW_BASE_CLASS = [ + 'relative flex w-full cursor-default select-none rounded-md px-2 py-1 text-left', + 'outline-hidden transition-colors hover:bg-(--ui-bg-tertiary)', + 'data-[highlighted]:bg-(--ui-bg-tertiary) data-[highlighted]:text-foreground' +].join(' ') + interface ComposerTriggerPopoverProps { activeIndex: number items: readonly Unstable_TriggerItem[] @@ -63,6 +72,9 @@ export function ComposerTriggerPopover({ }: ComposerTriggerPopoverProps) { const { t } = useI18n() const copy = t.composer + const isSlash = kind === '/' + + let lastGroup: string | undefined return (
{items.length === 0 ? ( - - {kind === '@' ? ( - <> - {copy.lookupTry} @file: {copy.lookupOr}{' '} - @folder:. - - ) : ( - <> - {copy.lookupTry} /help. - - )} - + loading ? ( +
+ + {copy.lookupLoading} +
+ ) : ( + + {kind === '@' ? ( + <> + {copy.lookupTry} @file: {copy.lookupOr}{' '} + @folder:. + + ) : ( + <> + {copy.lookupTry} /help. + + )} + + ) ) : ( items.map((item, index) => { - const meta = item.metadata as { display?: string; meta?: string } | undefined - const display = meta?.display ?? (kind === '/' ? `/${item.label}` : item.label) + const meta = item.metadata as RowMeta | undefined + const display = meta?.display ?? (isSlash ? `/${item.label}` : item.label) const description = meta?.meta || item.description + const group = meta?.group?.trim() + const showHeader = isSlash && Boolean(group) && group !== lastGroup + const isFirstHeader = lastGroup === undefined + lastGroup = group || lastGroup + const active = index === activeIndex return ( - + + ) }) )} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index ab4f3f0eb0..0da2663954 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -98,6 +98,7 @@ import { RightSidebarPane } from './right-sidebar' import { $terminalTakeover } from './right-sidebar/store' import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' +import { SessionPickerOverlay } from './session-picker-overlay' import { SessionSwitcher } from './session-switcher' import { useContextSuggestions } from './session/hooks/use-context-suggestions' import { useCwdActions } from './session/hooks/use-cwd-actions' @@ -694,6 +695,7 @@ export function DesktopController() { handleSkinCommand, refreshSessions, requestGateway, + resumeStoredSession: resumeSession, selectedStoredSessionIdRef, startFreshSessionDraft, sttEnabled, @@ -829,6 +831,7 @@ export function DesktopController() { /> )} + diff --git a/apps/desktop/src/app/session-picker-overlay.tsx b/apps/desktop/src/app/session-picker-overlay.tsx new file mode 100644 index 0000000000..65344fcac2 --- /dev/null +++ b/apps/desktop/src/app/session-picker-overlay.tsx @@ -0,0 +1,32 @@ +import { useStore } from '@nanostores/react' + +import { SessionPickerDialog } from '@/components/session-picker' +import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session' + +interface SessionPickerOverlayProps { + onResume: (storedSessionId: string) => void +} + +/** + * Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens — + * the desktop equivalent of the TUI's sessions overlay. Resuming runs through + * the same `resumeSession` path the sidebar uses. + */ +export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) { + const open = useStore($sessionPickerOpen) + const gatewayOpen = useStore($gatewayState) === 'open' + const activeStoredSessionId = useStore($selectedStoredSessionId) + + if (!gatewayOpen) { + return null + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 96af1e8400..3418e0bad8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -1,6 +1,6 @@ import { cleanup, render, waitFor } from '@testing-library/react' import type { MutableRefObject } from 'react' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $composerAttachments, type ComposerAttachment } from '@/store/composer' @@ -55,6 +55,7 @@ function Harness({ onSeedState, refreshSessions, requestGateway, + resumeStoredSession, storedSessionId }: { busyRef?: MutableRefObject @@ -62,6 +63,7 @@ function Harness({ onSeedState?: (state: Record) => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + resumeStoredSession?: (storedSessionId: string) => Promise | void storedSessionId?: null | string }) { const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } @@ -69,6 +71,12 @@ function Harness({ current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId } const localBusyRef = busyRef ?? { current: false } + const stateRef = useRef({ + messages: [], + busy: false, + awaitingResponse: false, + interrupted: true + } as never) const actions = usePromptActions({ activeSessionId: RUNTIME_SESSION_ID, @@ -79,17 +87,14 @@ function Harness({ handleSkinCommand: () => '', refreshSessions, requestGateway, + resumeStoredSession: resumeStoredSession ?? (() => undefined), selectedStoredSessionIdRef, startFreshSessionDraft: () => undefined, sttEnabled: false, updateSessionState: (_sessionId, updater) => { // Seed with interrupted:true so we can prove a fresh submit clears it. - const next = updater({ - messages: [], - busy: false, - awaitingResponse: false, - interrupted: true - } as never) as unknown as Record + const next = updater(stateRef.current) as unknown as Record + stateRef.current = next as never onSeedState?.(next) return next as never @@ -190,6 +195,68 @@ describe('usePromptActions /title', () => { }) }) +describe('usePromptActions desktop slash pickers', () => { + beforeEach(() => { + setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })]) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => { + const resumeStoredSession = vi.fn(async () => undefined) + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + render( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + resumeStoredSession={resumeStoredSession} + /> + ) + + await handle!.submitText('/resume 20260610_130000_123abc') + + expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc') + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + }) + + it('marks a timed-out handoff as failed so the next attempt can retry', async () => { + vi.useFakeTimers() + const calls: { method: string; params?: Record }[] = [] + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'handoff.state') { + return { state: 'pending' } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />) + + const result = handle!.submitText('/handoff telegram') + await vi.advanceTimersByTimeAsync(61_000) + await result + + expect(calls.some(call => call.method === 'handoff.request')).toBe(true) + expect(calls).toContainEqual({ + method: 'handoff.fail', + params: { + error: expect.stringContaining('Timed out'), + session_id: RUNTIME_SESSION_ID + } + }) + }) +}) + describe('usePromptActions submit / queue drain semantics', () => { afterEach(() => { cleanup() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 167f0d3224..15831bb418 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { getProfiles, transcribeAudio } from '@/hermes' import { translateNow, type Translations, useI18n } from '@/i18n' +import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef, parseCommandDispatch, parseSlashCommand, pathLabel, + sessionTitle, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { type CommandsCatalogLike, + type DesktopActionId, + type DesktopPickerId, desktopSlashUnavailableMessage, filterDesktopCommandsCatalog, isDesktopSlashCommand, - isModelPickerCommand + resolveDesktopCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' @@ -38,11 +42,13 @@ import { $busy, $connection, $messages, + $sessions, $yoloActive, setAwaitingResponse, setBusy, setMessages, setModelPickerOpen, + setSessionPickerOpen, setSessions, setYoloActive } from '@/store/session' @@ -50,12 +56,30 @@ import { import type { ClientSessionState, FileAttachResponse, + HandoffFailResponse, + HandoffRequestResponse, + HandoffStateResponse, ImageAttachResponse, SessionSteerResponse, SessionTitleResponse, SlashExecResponse } from '../../types' +interface HandoffResult { + ok: boolean + error?: string +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function isSessionIdCandidate(value: string): boolean { + const trimmed = value.trim() + + return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) +} + function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -245,6 +269,7 @@ interface PromptActionsOptions { handleSkinCommand: (arg: string) => string refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void sttEnabled: boolean @@ -260,6 +285,15 @@ interface SubmitTextOptions { fromQueue?: boolean } +/** Everything a slash handler needs about the invocation it's serving. */ +interface SlashActionCtx { + arg: string + command: string + name: string + recordInput: boolean + sessionHint?: string +} + function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { const desktopCatalog = filterDesktopCommandsCatalog(catalog) @@ -310,6 +344,7 @@ export function usePromptActions({ handleSkinCommand, refreshSessions, requestGateway, + resumeStoredSession, selectedStoredSessionIdRef, startFreshSessionDraft, sttEnabled, @@ -320,7 +355,11 @@ export function usePromptActions({ const appendSessionTextMessage = useCallback( (sessionId: string, role: ChatMessage['role'], text: string) => { - const body = text.trim() + // Strip ANSI: slash-command output from the backend worker carries SGR + // color codes (e.g. "Unknown command" in red). The ESC byte is invisible + // in the chat panel, so without this the `[1;31m…[0m` payload leaks as + // literal text. + const body = stripAnsi(text).trim() if (!body) { return @@ -696,230 +735,124 @@ export function usePromptActions({ ] ) + // Queue a handoff of this session to a messaging platform and watch it to + // a terminal state. We only write the request through the gateway; the + // separate `hermes gateway` process performs the actual transfer, so we + // poll `handoff.state` (mirror of the CLI's block-poll) for the result. + const handoffSession = useCallback( + async ( + platform: string, + options?: { onProgress?: (state: string) => void; sessionId?: string } + ): Promise => { + const sid = options?.sessionId || activeSessionIdRef.current + + if (!sid) { + return { error: copy.sessionUnavailable, ok: false } + } + + const target = platform.trim().toLowerCase() + + if (!target) { + return { error: copy.handoff.failed(''), ok: false } + } + + try { + options?.onProgress?.('pending') + await requestGateway('handoff.request', { + platform: target, + session_id: sid + }) + } catch (err) { + return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false } + } + + const deadline = Date.now() + 60_000 + let lastState = 'pending' + + while (Date.now() < deadline) { + await delay(800) + + let record: HandoffStateResponse + + try { + record = await requestGateway('handoff.state', { session_id: sid }) + } catch { + continue + } + + const state = record.state || 'pending' + + if (state !== lastState) { + options?.onProgress?.(state) + lastState = state + } + + if (state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + if (state === 'failed') { + return { error: record.error || copy.handoff.failed(target), ok: false } + } + } + + const cleanup = await requestGateway('handoff.fail', { + error: copy.handoff.timedOut, + session_id: sid + }).catch(() => null) + + if (cleanup?.state === 'completed') { + appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target)) + notify({ kind: 'success', message: copy.handoff.success(target) }) + + return { ok: true } + } + + return { error: copy.handoff.timedOut, ok: false } + }, + [activeSessionIdRef, appendSessionTextMessage, copy, requestGateway] + ) + const executeSlashCommand = useCallback( async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => { - const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise => { - const command = commandText.trim() - const { name, arg } = parseSlashCommand(command) - const normalizedName = name.toLowerCase() + const ensureSessionId = async (sessionHint?: string) => + sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - if (!name) { - const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - - if (sessionId) { - appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) - } - - return - } - - if (normalizedName === 'new' || normalizedName === 'reset') { - startFreshSessionDraft() - - return - } - - if (normalizedName === 'branch' || normalizedName === 'fork') { - await branchCurrentSession() - - return - } - - // /yolo maps to the status-bar YOLO control — a per-session approval - // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm - // it locally; the session-create path applies it on the first message. - if (normalizedName === 'yolo') { - const sid = sessionHint || activeSessionIdRef.current - const next = !$yoloActive.get() - - if (!sid) { - setYoloActive(next) - notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) - - return - } - - try { - const active = await setSessionYolo(requestGateway, sid, next) - appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) - } catch { - notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) - } - - return - } - - // /model opens the desktop model picker overlay — the same full - // provider+model picker reachable from the status-bar model button — - // instead of the headless prompt_toolkit modal the slash worker can't - // render. With explicit args (`/model [--provider ...]`) run the - // switch directly through slash.exec so power users can still type it. - if (isModelPickerCommand(`/${normalizedName}`)) { - if (!arg.trim()) { - setModelPickerOpen(true) - - return - } - - const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) - - if (!sid) { - notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' }) - - return - } - - try { - const result = await requestGateway('slash.exec', { - session_id: sid, - command: command.replace(/^\/+/, '') - }) - - const body = result?.output || `/${name}: model switched` - appendSessionTextMessage( - sid, - 'system', - recordInput ? slashStatusText(command, body) : body - ) - } catch (err) { - appendSessionTextMessage( - sid, - 'system', - `error: ${err instanceof Error ? err.message : String(err)}` - ) - } - - return - } - - if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) { - notify({ kind: 'success', message: handleSkinCommand(arg) }) - - return - } - - // /profile selects which profile new chats open in — no app relaunch. - // A profile is per-session now, so an existing thread can't change its - // profile mid-stream; `/profile ` instead points the next new chat - // (and the current empty draft) at that profile's backend. - if (normalizedName === 'profile') { - const target = arg.trim() - const current = normalizeProfileKey($activeGatewayProfile.get()) - - if (!target) { - notify({ - kind: 'success', - message: copy.profileStatus(current) - }) - - return - } - - try { - const { profiles } = await getProfiles() - const match = profiles.find(profile => profile.name === target) - - if (!match) { - notify({ - kind: 'error', - title: copy.unknownProfile, - message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) - }) - - return - } - - const key = normalizeProfileKey(match.name) - - $newChatProfile.set(key) - // Swap the live gateway now so an empty draft sends into this - // profile immediately; an existing thread keeps its own profile. - await ensureGatewayProfile(key) - notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) - } catch (err) { - notifyError(err, copy.setProfileFailed) - } - - return - } - - const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend()) + // Resolve the target session plus a writer for inline slash output, or + // notify + return null when none can be created. Folds the ensure / bail / + // build-renderSlashOutput boilerplate every exec-style handler repeats. + const withSlashOutput = async ( + ctx: SlashActionCtx + ): Promise<{ render: (text: string) => void; sessionId: string } | null> => { + const sessionId = await ensureSessionId(ctx.sessionHint) if (!sessionId) { - notify({ - kind: 'error', - title: copy.sessionUnavailable, - message: copy.createSessionFailed - }) + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + return null + } + + const render = (text: string) => + appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text) + + return { render, sessionId } + } + + // `exec` commands (and unknown skill / quick commands the backend owns) + // run on the gateway and render their text output inline. This is the only + // path that talks to slash.exec / command.dispatch. + async function runExec(ctx: SlashActionCtx): Promise { + const { arg, command, name } = ctx + const resolved = await withSlashOutput(ctx) + + if (!resolved) { return } - const renderSlashOutput = (text: string) => - appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) - - // /title renames the session. Route through the gateway's - // `session.title` RPC — the same path the TUI uses — NOT the REST - // renameSession endpoint and NOT the slash worker. - // - // Why not the slash worker: it's a separate HermesCLI subprocess whose - // SQLite write to the shared state.db can silently fail (notably on - // Windows), and it never refreshes the sidebar. - // - // Why not REST renameSession: `sessionId` here is the *runtime* session - // id returned by session.create — it is NOT the stored DB `sessions.id`, - // and session.create deliberately does not persist a DB row until the - // first turn. The REST PATCH endpoint resolves against the sessions - // table, so a runtime id (or a brand-new, not-yet-persisted session) - // 404s with "Session not found" on every platform. See #38508 / #38576. - // - // session.title maps the runtime id to the in-memory session, writes - // through the gateway's own DB connection, and QUEUES the title - // (`pending: true`) when the row isn't persisted yet — so it works for a - // fresh chat too. refreshSessions() then pulls the authoritative title - // back into the sidebar. A bare `/title` (no arg) still falls through to - // the worker to display the current title. - if (normalizedName === 'title' && arg) { - try { - const result = await requestGateway('session.title', { - session_id: sessionId, - title: arg - }) - - const finalTitle = (result?.title || arg).trim() - const queued = result?.pending === true - - setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) - await refreshSessions().catch(() => undefined) - renderSlashOutput( - finalTitle - ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` - : 'Session title cleared.' - ) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - - return - } - - if (normalizedName === 'skin') { - renderSlashOutput(handleSkinCommand(arg)) - - return - } - - if (name === 'help' || name === 'commands') { - try { - const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) - - renderSlashOutput(renderCommandsCatalog(catalog, copy)) - } catch (err) { - renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) - } - - return - } + const { render: renderSlashOutput, sessionId } = resolved if (!isDesktopSlashCommand(name)) { renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) @@ -943,11 +876,7 @@ export function usePromptActions({ try { const dispatch = parseCommandDispatch( - await requestGateway('command.dispatch', { - session_id: sessionId, - name, - arg - }) + await requestGateway('command.dispatch', { session_id: sessionId, name, arg }) ) if (!dispatch) { @@ -994,6 +923,261 @@ export function usePromptActions({ } } + // One handler per `action` command. Adding a desktop-native command is a + // registry row in desktop-slash-commands.ts plus an entry here — never a + // new branch in a dispatch ladder. + const actionHandlers: Record Promise> = { + new: async () => { + startFreshSessionDraft() + }, + branch: async () => { + await branchCurrentSession() + }, + // /yolo maps to the status-bar YOLO control — a per-session approval + // bypass, same scope as the TUI's Shift+Tab. With no session yet we arm + // it locally; the session-create path applies it on the first message. + yolo: async ({ sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const next = !$yoloActive.get() + + if (!sid) { + setYoloActive(next) + notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff }) + + return + } + + try { + const active = await setSessionYolo(requestGateway, sid, next) + appendSessionTextMessage(sid, 'system', copy.yoloSystem(active)) + } catch { + notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed }) + } + }, + // /handoff hands this session to a messaging platform. The platform is + // completed inline in the slash popover (backend _handoff_completions), + // so there is no overlay: `/handoff ` runs the desktop's own + // handoff RPC. cli_only on the backend, so it must not reach slash.exec. + handoff: async ({ arg, command, recordInput, sessionHint }) => { + const platform = arg.trim() + + if (!platform) { + notify({ kind: 'success', message: copy.handoff.pickPlatform }) + + return + } + + const sid = sessionHint || activeSessionIdRef.current + + if (!sid) { + notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) + + return + } + + const result = await handoffSession(platform, { sessionId: sid }) + + if (!result.ok && result.error) { + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error) + } + }, + // /profile selects which profile new chats open in — no app relaunch. + // A profile is per-session now, so an existing thread can't change its + // profile mid-stream; `/profile ` points the next new chat (and + // the current empty draft) at that profile's backend. + profile: async ({ arg }) => { + const target = arg.trim() + const current = normalizeProfileKey($activeGatewayProfile.get()) + + if (!target) { + notify({ kind: 'success', message: copy.profileStatus(current) }) + + return + } + + try { + const { profiles } = await getProfiles() + const match = profiles.find(profile => profile.name === target) + + if (!match) { + notify({ + kind: 'error', + title: copy.unknownProfile, + message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', ')) + }) + + return + } + + const key = normalizeProfileKey(match.name) + + $newChatProfile.set(key) + await ensureGatewayProfile(key) + notify({ kind: 'success', message: copy.newChatsProfile(match.name) }) + } catch (err) { + notifyError(err, copy.setProfileFailed) + } + }, + skin: async ({ arg, command, recordInput, sessionHint }) => { + const sid = sessionHint || activeSessionIdRef.current + const message = handleSkinCommand(arg) + + // No session to print into yet — surface it as a toast instead of + // spinning up a backend session just to change the theme. + if (!sid) { + notify({ kind: 'success', message }) + + return + } + + appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message) + }, + // /title renames via the gateway's session.title RPC — the same + // path the TUI uses, NOT REST renameSession (which 404s on runtime ids) + // nor the slash worker (whose DB write can silently fail). Bare /title + // shows the current title, which the worker owns, so delegate to exec. + title: async ctx => { + if (!ctx.arg) { + await runExec(ctx) + + return + } + + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + const { arg } = ctx + + try { + const result = await requestGateway('session.title', { + session_id: sessionId, + title: arg + }) + + const finalTitle = (result?.title || arg).trim() + const queued = result?.pending === true + + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + await refreshSessions().catch(() => undefined) + renderSlashOutput( + finalTitle + ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` + : 'Session title cleared.' + ) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + }, + help: async ctx => { + const resolved = await withSlashOutput(ctx) + + if (!resolved) { + return + } + + const { render: renderSlashOutput, sessionId } = resolved + + try { + const catalog = await requestGateway('commands.catalog', { session_id: sessionId }) + + renderSlashOutput(renderCommandsCatalog(catalog, copy)) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + // Picker commands open a desktop overlay; a typed arg is resolved by that + // picker so the command never dead-ends or falls through to the backend. + const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise => { + if (pickerId === 'model') { + if (!ctx.arg.trim()) { + setModelPickerOpen(true) + + return + } + + // Power users can still type `/model ` — run it on the backend. + await runExec(ctx) + + return + } + + // session picker — /resume, /sessions, /switch + const query = ctx.arg.trim() + + if (!query) { + setSessionPickerOpen(true) + + return + } + + const sessions = $sessions.get() + const lower = query.toLowerCase() + + const match = + sessions.find(session => session.id === query) || + sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) || + sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower)) + + if (!match) { + if (isSessionIdCandidate(query)) { + await resumeStoredSession(query) + + return + } + + notify({ kind: 'error', message: copy.resumeFailed }) + + return + } + + await resumeStoredSession(match.id) + } + + // The whole dispatcher: resolve the command's desktop surface, then act on + // its kind. No per-command ladder — behavior lives in the registry. + async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise { + const command = commandText.trim() + const { name, arg } = parseSlashCommand(command) + + if (!name) { + const sessionId = await ensureSessionId(sessionHint) + + if (sessionId) { + appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand) + } + + return + } + + const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint } + const surface = resolveDesktopCommand(`/${name}`)?.surface + + switch (surface?.kind) { + case 'unavailable': { + const resolved = await withSlashOutput(ctx) + resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`) + + return + } + + case 'picker': + return openPicker(surface.picker, ctx) + + case 'action': + return actionHandlers[surface.action](ctx) + + default: + // exec spec, or an unknown skill / quick command the backend owns. + return runExec(ctx) + } + } + await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true) }, [ @@ -1004,8 +1188,10 @@ export function usePromptActions({ copy, createBackendSessionForSend, handleSkinCommand, + handoffSession, refreshSessions, requestGateway, + resumeStoredSession, startFreshSessionDraft, submitPromptText ] @@ -1314,6 +1500,7 @@ export function usePromptActions({ cancelRun, editMessage, handleThreadMessagesChange, + handoffSession, reloadFromMessage, steerPrompt, submitText, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 672beb9a08..01694dc822 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -61,6 +61,26 @@ export interface SessionTitleResponse { session_key?: string } +export interface HandoffRequestResponse { + queued?: boolean + session_key?: string + platform?: string + // Human-readable home channel name for the destination platform. + home_name?: string +} + +export interface HandoffStateResponse { + // '' | 'pending' | 'running' | 'completed' | 'failed' + state?: string + platform?: string + error?: string +} + +export interface HandoffFailResponse { + failed?: boolean + state?: string +} + export interface ExecCommandDispatchResponse { type: 'exec' | 'plugin' output?: string diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 79f772d450..b870913b01 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) { return `${inner}` } -export function directiveIconElement(type: string) { +function iconElementFromPaths(paths: string[]) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.setAttribute('class', 'size-3 shrink-0 opacity-80') svg.setAttribute('fill', 'none') @@ -74,7 +74,7 @@ export function directiveIconElement(type: string) { svg.setAttribute('viewBox', '0 0 24 24') svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') - for (const d of iconPathsFor(type)) { + for (const d of paths) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') path.setAttribute('d', d) svg.append(path) @@ -83,6 +83,46 @@ export function directiveIconElement(type: string) { return svg } +export function directiveIconElement(type: string) { + return iconElementFromPaths(iconPathsFor(type)) +} + +/** Per-type slash-command pill styling. The composer inserts these chips when a + * command is picked; the kind drives a theme-aware accent so commands, skills, + * and themes read distinctly (Cursor-style). */ +export type SlashChipKind = 'command' | 'skill' | 'theme' + +const SLASH_ICON_PATHS: Record = { + command: ['M5 7l5 5l-5 5', 'M12 19l7 0'], + skill: ['M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11'], + theme: [ + 'M3 21v-4a4 4 0 1 1 4 4h-4', + 'M21 3a16 16 0 0 0 -12.8 10.2', + 'M21 3a16 16 0 0 1 -10.2 12.8', + 'M10.6 9a9 9 0 0 1 4.4 4.4' + ] +} + +const SLASH_CHIP_VARIANT: Record = { + command: + 'bg-[color-mix(in_srgb,var(--ui-accent)_14%,transparent)] text-[color-mix(in_srgb,var(--ui-accent)_82%,var(--foreground))]', + skill: + 'bg-[color-mix(in_srgb,var(--ui-warm)_18%,transparent)] text-[color-mix(in_srgb,var(--ui-warm)_82%,var(--foreground))]', + theme: + 'bg-[color-mix(in_srgb,var(--ui-accent-secondary)_16%,transparent)] text-[color-mix(in_srgb,var(--ui-accent-secondary)_82%,var(--foreground))]' +} + +export const SLASH_CHIP_BASE_CLASS = + 'mx-0.5 inline-flex max-w-64 items-center gap-1 rounded px-1.5 py-0.5 align-middle text-[0.86em] font-medium leading-none' + +export function slashChipClass(kind: SlashChipKind): string { + return `${SLASH_CHIP_BASE_CLASS} ${SLASH_CHIP_VARIANT[kind]}` +} + +export function slashIconElement(kind: SlashChipKind) { + return iconElementFromPaths(SLASH_ICON_PATHS[kind]) +} + const DirectiveIcon: FC<{ type: string }> = ({ type }) => ( { const slashStatus = text.match(SLASH_STATUS_RE) if (slashStatus?.groups) { + const output = slashStatus.groups.output.trim() + // Single-line status (e.g. "model → x") reads best centered inline; padded + // multiline output (catalogs, usage tables) needs left-aligned, wider room + // or the column alignment breaks. + const multiline = output.includes('\n') + return ( {slashStatus.groups.command} - · - + {multiline ? ( + + ) : ( + <> + · + + + )} ) } + const multiline = text.includes('\n') + return ( diff --git a/apps/desktop/src/components/session-picker.tsx b/apps/desktop/src/components/session-picker.tsx new file mode 100644 index 0000000000..048fa32a20 --- /dev/null +++ b/apps/desktop/src/components/session-picker.tsx @@ -0,0 +1,108 @@ +import { useQuery } from '@tanstack/react-query' +import { Dialog as DialogPrimitive } from 'radix-ui' +import { useEffect, useMemo, useState } from 'react' + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { listSessions } from '@/hermes' +import { useI18n } from '@/i18n' +import { sessionTitle } from '@/lib/chat-runtime' +import { Check, MessageCircle } from '@/lib/icons' +import { cn } from '@/lib/utils' + +interface SessionPickerDialogProps { + /** Stored id of the session currently open, so it can be flagged in the list. */ + activeStoredSessionId?: string | null + onOpenChange: (open: boolean) => void + onResume: (storedSessionId: string) => void + open: boolean +} + +/** + * Desktop equivalent of the TUI's sessions overlay (`/resume`, `/sessions`, + * `/switch`): a focused, type-to-filter list of recent sessions that resumes + * the picked one. Mirrors the command palette's cmdk surface but scoped to + * sessions only, so `/resume` feels first-class instead of falling through to + * the headless slash worker (which can't render the picker). + */ +export function SessionPickerDialog({ + activeStoredSessionId, + onOpenChange, + onResume, + open +}: SessionPickerDialogProps) { + const { t } = useI18n() + const [search, setSearch] = useState('') + + const sessionsQuery = useQuery({ + enabled: open, + queryFn: () => listSessions(200, 1, 'exclude'), + queryKey: ['session-picker', 'sessions'] + }) + + useEffect(() => { + if (!open) { + setSearch('') + } + }, [open]) + + const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data]) + + return ( + + + + + {t.commandCenter.sections.sessions} + + + + {t.commandCenter.noResults} + + {sessions.map(session => { + const title = sessionTitle(session) + const preview = session.preview?.trim() + + return ( + { + onResume(session.id) + onOpenChange(false) + }} + value={`${title} ${preview ?? ''} ${session.id}`} + > + + + {title} + {preview ? ( + {preview} + ) : null} + + + + ) + })} + + + + + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5aaf090d7e..5a18d47efa 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1778,7 +1778,14 @@ export const en: Translations = { clipboard: 'Clipboard', noClipboardImage: 'No image found in clipboard', clipboardPasteFailed: 'Clipboard paste failed', - dropFiles: 'Drop files' + dropFiles: 'Drop files', + handoff: { + pickPlatform: 'Choose a destination', + success: platform => `Handed off to ${platform}. Resume here anytime.`, + systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`, + failed: error => `Handoff failed: ${error}`, + timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?' + } }, errors: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 956788067e..36634a6c02 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1914,7 +1914,14 @@ export const ja = defineLocale({ clipboard: 'クリップボード', noClipboardImage: 'クリップボードに画像が見つかりません', clipboardPasteFailed: 'クリップボードからの貼り付けに失敗しました', - dropFiles: 'ファイルをドロップ' + dropFiles: 'ファイルをドロップ', + handoff: { + pickPlatform: '送信先を選択', + success: platform => `${platform} に引き継ぎました。いつでもここで再開できます。`, + systemNote: platform => `↻ ${platform} に引き継ぎました — いつでもここで再開できます。`, + failed: error => `引き継ぎに失敗しました: ${error}`, + timedOut: 'ゲートウェイの待機がタイムアウトしました。`hermes gateway` は起動していますか?' + } }, errors: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 77424e426a..7a10e5f3d1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1437,6 +1437,13 @@ export interface Translations { noClipboardImage: string clipboardPasteFailed: string dropFiles: string + handoff: { + pickPlatform: string + success: (platform: string) => string + systemNote: (platform: string) => string + failed: (error: string) => string + timedOut: string + } } errors: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9f045c4d02..830dc47513 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1873,7 +1873,14 @@ export const zhHant = defineLocale({ clipboard: '剪貼簿', noClipboardImage: '剪貼簿中沒有圖片', clipboardPasteFailed: '剪貼簿貼上失敗', - dropFiles: '拖曳檔案' + dropFiles: '拖曳檔案', + handoff: { + pickPlatform: '選擇目標平台', + success: platform => `已移交到 ${platform}。隨時可在此處恢復。`, + systemNote: platform => `↻ 已移交到 ${platform} — 隨時可在此處恢復。`, + failed: error => `移交失敗:${error}`, + timedOut: '等待閘道逾時。`hermes gateway` 是否正在執行?' + } }, errors: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f6b119a277..dbad00cf5d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1956,7 +1956,14 @@ export const zh: Translations = { clipboard: '剪贴板', noClipboardImage: '剪贴板中没有图片', clipboardPasteFailed: '粘贴剪贴板失败', - dropFiles: '拖放文件' + dropFiles: '拖放文件', + handoff: { + pickPlatform: '选择目标平台', + success: platform => `已移交到 ${platform}。随时可在此处恢复。`, + systemNote: platform => `↻ 已移交到 ${platform} — 随时可在此处恢复。`, + failed: error => `移交失败:${error}`, + timedOut: '等待网关超时。`hermes gateway` 是否正在运行?' + } }, errors: { diff --git a/apps/desktop/src/lib/ansi.ts b/apps/desktop/src/lib/ansi.ts index f30987ec60..c7770e8b77 100644 --- a/apps/desktop/src/lib/ansi.ts +++ b/apps/desktop/src/lib/ansi.ts @@ -173,3 +173,14 @@ 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, '') +} diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index de0e72ec28..d37738173c 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -7,7 +7,9 @@ import { filterDesktopCommandsCatalog, isDesktopSlashCommand, isDesktopSlashSuggestion, - isModelPickerCommand + isModelPickerCommand, + isPickerCommand, + resolveDesktopCommand } from './desktop-slash-commands' describe('desktop slash command curation', () => { @@ -38,6 +40,18 @@ describe('desktop slash command curation', () => { 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', () => { expect(isDesktopSlashSuggestion('/reset')).toBe(false) expect(isDesktopSlashCommand('/reset')).toBe(true) @@ -74,6 +88,24 @@ describe('desktop slash command curation', () => { ['/new', 'Start a new desktop chat'], ['/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) }) @@ -123,4 +155,26 @@ describe('desktop slash command curation', () => { expect(isModelPickerCommand('/new')).toBe(false) expect(isModelPickerCommand('/skills')).toBe(false) }) + + it('gives /resume (and its aliases) a first-class session picker surface', () => { + expect(isPickerCommand('/resume', 'session')).toBe(true) + expect(isPickerCommand('/sessions', 'session')).toBe(true) + expect(isPickerCommand('/switch', 'session')).toBe(true) + // Unlike /model, /resume shows in the popover; its aliases stay hidden. + expect(isDesktopSlashSuggestion('/resume')).toBe(true) + expect(isDesktopSlashSuggestion('/sessions')).toBe(false) + expect(isDesktopSlashCommand('/switch')).toBe(true) + // The session picker is distinct from the model picker. + expect(isModelPickerCommand('/resume')).toBe(false) + }) + + it('resolves commands and aliases to their declared surface', () => { + expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' }) + expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' }) + expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' }) + expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' }) + // Skill / quick commands aren't in the registry. + expect(resolveDesktopCommand('/gif-search')).toBeNull() + }) }) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index e373ac9431..d898a6c83f 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -22,110 +22,161 @@ export interface DesktopThemeCommandOption { name: string } -const DESKTOP_COMMAND_META = [ - ['/agents', 'Show active desktop sessions and running tasks'], - ['/background', 'Run a prompt in the background'], - ['/branch', 'Branch the latest message into a new chat'], - ['/compress', 'Compress this conversation context'], - ['/debug', 'Create a debug report'], - ['/goal', 'Manage the standing goal for this session'], - ['/help', 'Show desktop slash commands'], - ['/new', 'Start a new desktop chat'], - ['/profile', 'Switch the active Hermes profile'], - ['/queue', 'Queue a prompt for the next turn'], - ['/resume', 'Resume a saved session'], - ['/retry', 'Retry the last user message'], - ['/rollback', 'List or restore filesystem checkpoints'], - ['/skin', 'Switch desktop theme or cycle to the next one'], - ['/status', 'Show current session status'], - ['/steer', 'Steer the current run after the next tool call'], - ['/stop', 'Stop running background processes'], - ['/title', 'Rename the current session'], - ['/undo', 'Remove the last user/assistant exchange'], - ['/usage', 'Show token usage for this session'], - ['/version', 'Show Hermes Agent version'], - ['/yolo', 'Toggle YOLO — auto-approve dangerous commands'] -] as const +/** + * Local client action a command resolves to. Each id maps to exactly one + * handler in the dispatcher (`use-prompt-actions`), so adding a command never + * means adding a branch to a switch ladder — you add a row here + a handler + * keyed by the id. + */ +export type DesktopActionId = + | 'branch' + | 'handoff' + | 'help' + | 'new' + | 'profile' + | 'skin' + | 'title' + | 'yolo' -const DESKTOP_COMMANDS: ReadonlySet = new Set(DESKTOP_COMMAND_META.map(([command]) => command)) +/** A command fulfilled by opening a desktop overlay picker. */ +export type DesktopPickerId = 'model' | 'session' -const DESKTOP_ALIASES = new Map([ - ['/bg', '/background'], - ['/btw', '/background'], - ['/fork', '/branch'], - ['/q', '/queue'], - ['/reload_mcp', '/reload-mcp'], - ['/reload_skills', '/reload-skills'], - ['/reset', '/new'], - ['/tasks', '/agents'] -]) +/** Why a known Hermes command has no desktop UI surface. */ +export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal' -const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap = new Map(DESKTOP_COMMAND_META) +/** + * How the desktop fulfils a command. This is the single discriminator the + * dispatcher, popover, pills, and pickers all read — no parallel block-lists. + * + * - `action` → handled by a local client handler (new chat, branch, …) + * - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is + * resolved by that picker instead of falling through + * - `exec` → runs on the backend via slash.exec / command.dispatch and + * renders its text output inline + * - `unavailable`→ a known command with genuinely no desktop UI (terminal-only, + * messaging-only, …); shows a reason instead of executing + */ +export type DesktopCommandSurface = + | { kind: 'action'; action: DesktopActionId } + | { kind: 'picker'; picker: DesktopPickerId } + | { kind: 'exec' } + | { kind: 'unavailable'; reason: DesktopUnavailableReason } -const PICKER_OWNED_COMMANDS = new Set(['/model']) +export interface DesktopCommandSpec { + /** Canonical command, leading slash included (e.g. `/resume`). */ + name: string + /** Popover/help label; omitted for unavailable commands (never surfaced). */ + description?: string + aliases?: string[] + surface: DesktopCommandSurface + /** + * Hide from the slash popover / completions while still letting it execute. + * Used for picker commands reachable from chrome (the model picker lives on + * the status bar), so the popover doesn't dead-end on inline completion. + */ + hidden?: boolean + /** + * The command has an inline options "screen" (theme / personality / session / + * platform / toolset list). Picking the bare command in the popover expands to + * that argument step instead of committing — mirroring typing `/ ` by hand. + */ + args?: boolean +} -const TERMINAL_ONLY_COMMANDS = new Set([ - '/browser', - '/busy', - '/clear', - '/commands', - '/compact', - '/config', - '/copy', - '/cron', - '/details', - '/exit', - '/footer', - '/gateway', - '/gquota', - '/history', - '/image', - '/indicator', - '/logs', - '/mouse', - '/paste', - '/platforms', - '/plugins', - '/quit', - '/redraw', - '/reload', - '/restart', - '/save', - '/sb', - '/set-home', - '/sethome', - '/snap', - '/snapshot', - '/statusbar', - '/toolsets', - '/tools', - '/update', - '/verbose' -]) +const exec = (): DesktopCommandSurface => ({ kind: 'exec' }) +const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id }) +const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id }) +const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason }) -const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny']) +/** + * THE source of truth for desktop slash commands. Everything below — execution + * gating, popover suggestions, catalog filtering, pill grouping, and the + * dispatcher's behavior — derives from this one table. + */ +const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ + // Local client actions + { name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') }, + { name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') }, + { name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') }, + { name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true }, + { name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') }, + { name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true }, + { name: '/title', description: 'Rename the current session', surface: action('title') }, + { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, -const SETTINGS_OWNED_COMMANDS = new Set(['/skills']) + // Overlay pickers + { name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true }, + { + name: '/resume', + description: 'Resume a saved session', + aliases: ['/sessions', '/switch'], + surface: picker('session'), + args: true + }, -const ADVANCED_COMMANDS = new Set([ - '/curator', - '/fast', - '/insights', - '/kanban', - '/personality', - '/reasoning', - '/reload-mcp', - '/reload-skills', - '/voice' -]) + // Backend-executed commands that render useful inline output + { name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() }, + { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() }, + { name: '/compress', description: 'Compress this conversation context', surface: exec() }, + { name: '/debug', description: 'Create a debug report', surface: exec() }, + { name: '/goal', description: 'Manage the standing goal for this session', surface: exec() }, + { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true }, + { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() }, + { name: '/retry', description: 'Retry the last user message', surface: exec() }, + { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, + { name: '/save', description: 'Save the current transcript to JSON', surface: exec() }, + { name: '/status', description: 'Show current session status', surface: exec() }, + { name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() }, + { name: '/stop', description: 'Stop running background processes', surface: exec() }, + { name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true }, + { name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() }, + { name: '/usage', description: 'Show token usage for this session', surface: exec() }, + { name: '/version', description: 'Show Hermes Agent version', surface: exec() }, -const BLOCKED_COMMANDS = new Set([ - ...PICKER_OWNED_COMMANDS, - ...TERMINAL_ONLY_COMMANDS, - ...MESSAGING_ONLY_COMMANDS, - ...SETTINGS_OWNED_COMMANDS, - ...ADVANCED_COMMANDS -]) + // No desktop surface, but carry an alias (underscore spelling variants). + { name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') }, + { name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') } +] + +// Known commands with no desktop surface (and no alias) — a flat name list +// per reason beats 40 identical object literals. +const NO_DESKTOP_SURFACE: Record = { + terminal: [ + '/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', + '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs', + '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart', + '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' + ], + messaging: ['/approve', '/deny'], + settings: ['/skills'], + advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] +} + +const ALL_SPECS: readonly DesktopCommandSpec[] = [ + ...DESKTOP_COMMAND_SPECS, + ...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap( + ([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) })) + ) +] + +const SPEC_BY_NAME = new Map(ALL_SPECS.map(spec => [spec.name, spec])) + +const ALIAS_TO_CANONICAL = new Map( + ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const)) +) + +const UNAVAILABLE_MESSAGE: Record string> = { + advanced: command => + `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`, + messaging: command => `${command} is only used from messaging platforms.`, + settings: command => `${command} is managed from the desktop sidebar.`, + terminal: command => `${command} is only available in the terminal interface.` +} + +const PICKER_UNAVAILABLE_MESSAGE: Record string> = { + model: command => `${command} uses the desktop model picker instead of a slash command.`, + session: command => `${command} uses the desktop session picker instead of a slash command.` +} function normalizeCommand(command: string): string { const trimmed = command.trim() @@ -137,27 +188,25 @@ function normalizeCommand(command: string): string { export function canonicalDesktopSlashCommand(command: string): string { const normalized = normalizeCommand(command) - return DESKTOP_ALIASES.get(normalized) || normalized + return ALIAS_TO_CANONICAL.get(normalized) || normalized } -export function isDesktopSlashCommand(command: string): boolean { +/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */ +export function resolveDesktopCommand(command: string): DesktopCommandSpec | null { + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null +} + +function isKnownHermesSlashCommand(command: string): boolean { const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) - if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) { - return false - } - - return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized) + return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized) } /** * An "extension" command is anything the backend surfaces that is NOT one of * Hermes' built-in slash commands — i.e. skill commands (`/gif-search`, * `/codex`, …) and user-defined quick commands. These are user-activated, so - * they should appear in the desktop slash palette even though they aren't in - * the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in - * `isDesktopSlashCommand` that already lets them EXECUTE when typed. + * they appear in the desktop slash palette and execute when typed. */ export function isDesktopSlashExtensionCommand(command: string): boolean { const normalized = normalizeCommand(command) @@ -169,63 +218,85 @@ export function isDesktopSlashExtensionCommand(command: string): boolean { return !isKnownHermesSlashCommand(normalized) } -export function isDesktopSlashSuggestion(command: string): boolean { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) +/** Gates execution: true unless the command is a known no-desktop-surface command. */ +export function isDesktopSlashCommand(command: string): boolean { + const spec = resolveDesktopCommand(command) - // Surface skill / quick commands (extensions the backend provides) alongside - // the curated built-ins. Built-in aliases stay hidden so the popover isn't - // cluttered with duplicates. - if (isDesktopSlashExtensionCommand(normalized)) { - return true + if (spec) { + return spec.surface.kind !== 'unavailable' } - return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized) + return isDesktopSlashExtensionCommand(command) +} + +/** Gates discovery in the popover/completions. */ +export function isDesktopSlashSuggestion(command: string): boolean { + const normalized = normalizeCommand(command) + + // Aliases stay hidden so the popover isn't cluttered with duplicates. + if (ALIAS_TO_CANONICAL.has(normalized)) { + return false + } + + const spec = SPEC_BY_NAME.get(normalized) + + if (spec) { + return spec.surface.kind !== 'unavailable' && !spec.hidden + } + + // Skill / quick commands the backend provides. + return isDesktopSlashExtensionCommand(normalized) } /** - * True for commands the desktop fulfils by opening the model picker overlay - * (e.g. `/model`) rather than executing a slash command. The caller opens the - * picker UI instead of printing the "uses the desktop model picker" notice. + * True for commands the desktop fulfils by opening an overlay picker + * (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker. */ -export function isModelPickerCommand(command: string): boolean { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) +export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean { + const surface = resolveDesktopCommand(command)?.surface - return PICKER_OWNED_COMMANDS.has(canonical) + if (surface?.kind !== 'picker') { + return false + } + + return picker ? surface.picker === picker : true +} + +/** Back-compat shim for the model picker check. */ +export function isModelPickerCommand(command: string): boolean { + return isPickerCommand(command, 'model') } export function desktopSlashUnavailableMessage(command: string): string | null { - const normalized = normalizeCommand(command) - const canonical = canonicalDesktopSlashCommand(normalized) + const canonical = canonicalDesktopSlashCommand(command) + const surface = SPEC_BY_NAME.get(canonical)?.surface - if (PICKER_OWNED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.` + if (!surface) { + return null } - if (SETTINGS_OWNED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is managed from the desktop sidebar.` + if (surface.kind === 'unavailable') { + return UNAVAILABLE_MESSAGE[surface.reason](canonical) } - if (MESSAGING_ONLY_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is only used from messaging platforms.` - } - - if (ADVANCED_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.` - } - - if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) { - return `/${canonical.slice(1)} is only available in the terminal interface.` + if (surface.kind === 'picker') { + return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical) } return null } export function desktopSlashDescription(command: string, fallback = ''): string { - const canonical = canonicalDesktopSlashCommand(command) + return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback +} - return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback +/** + * True when picking the bare command should expand to its inline argument + * options (theme / personality / session / platform / toolset) rather than + * committing immediately. Lets the popover act as a two-step picker. + */ +export function desktopSlashCommandTakesArgs(command: string): boolean { + return resolveDesktopCommand(command)?.args ?? false } export function desktopSkinSlashCompletions( @@ -274,13 +345,36 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm ?.filter(([command]) => isDesktopSlashSuggestion(command)) .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() + + 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 { ...catalog, ...(categories ? { categories } : {}), - ...(pairs ? { pairs } : {}) + ...(pairs ? { pairs } : {}), + ...(hasSkillCount ? { skill_count: skillCount } : {}) } } - -function isKnownHermesSlashCommand(command: string): boolean { - return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command) -} diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 6df96946bf..4139915cea 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -200,6 +200,7 @@ export const $availablePersonalities = atom([]) export const $introSeed = atom(0) export const $contextSuggestions = atom([]) export const $modelPickerOpen = atom(false) +export const $sessionPickerOpen = atom(false) export const setConnection = (next: Updater) => updateAtom($connection, next) export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) @@ -249,6 +250,7 @@ export const setAvailablePersonalities = (next: Updater) => updateAtom export const setIntroSeed = (next: Updater) => updateAtom($introSeed, next) export const setContextSuggestions = (next: Updater) => updateAtom($contextSuggestions, next) export const setModelPickerOpen = (next: Updater) => updateAtom($modelPickerOpen, next) +export const setSessionPickerOpen = (next: Updater) => updateAtom($sessionPickerOpen, next) // Watchdog tracking — when does a "working" session count as stuck? // Long-running tool calls (LLM inference, long shell commands, web fetches) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f23d1960da..aded4d41d8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer): except Exception: pass + @staticmethod + def _tools_completions(sub_text: str, sub_lower: str): + """Yield completions for /tools — subcommand + toolset/MCP-server name. + + Handles both ``/tools `` (suggesting ``list|disable|enable``) and + ``/tools enable `` / ``/tools disable `` (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 + def _handoff_completions(sub_text: str, sub_lower: str): + """Yield platform completions for /handoff. + + Offers connected (enabled + configured) gateway platforms. A recorded + home channel is NOT required to list a platform — it's often learned at + runtime — so the meta hints whether one is set yet. Completes only the + first arg (the platform); once one is chosen, stop. + """ + parts = sub_text.split() + trailing_space = sub_text.endswith(" ") + if len(parts) > 1 or (len(parts) == 1 and trailing_space): + return + partial = "" if (not parts or trailing_space) else parts[-1] + partial_lower = partial.lower() + try: + from gateway.config import load_gateway_config + + gw = load_gateway_config() + platforms = gw.get_connected_platforms() + except Exception: + return + for platform in platforms: + name = platform.value + if not name.startswith(partial_lower): + continue + try: + home = gw.get_home_channel(platform) + except Exception: + home = None + meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here" + yield Completion( + name, + start_position=-len(partial), + display=name, + display_meta=meta, + ) + @staticmethod def _personality_completions(sub_text: str, sub_lower: str): """Yield completions for /personality from configured personalities.""" try: - from hermes_cli.config import load_config - personalities = load_config().get("agent", {}).get("personalities", {}) + # Resolve from the same source the runtime applies personalities — + # agent.personalities via the CLI config (which ships the built-ins). + # load_config()'s schema has no agent.personalities, so the completer + # used to come back empty even with personalities available. + from cli import load_cli_config + + personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {} if "none".startswith(sub_lower) and "none" != sub_lower: yield Completion( "none", @@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer): yield from self._personality_completions(sub_text, sub_lower) 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 + + if base_cmd == "/handoff": + yield from self._handoff_completions(sub_text, sub_lower) + return + # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): for sub in SUBCOMMANDS[base_cmd]: diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 6a63ebe73e..62c2be4ab7 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -691,6 +691,169 @@ class TestSubcommandCompletion: completions = _completions(SlashCommandCompleter(), "/help ") 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 + + def _fake_gateway(self, monkeypatch, platforms): + """Patch load_gateway_config with a fake whose connected platforms are + the keys of `platforms` (name -> home as None or a (chat_id, name) tuple). + """ + from types import SimpleNamespace + + enums = {name: SimpleNamespace(value=name) for name in platforms} + homes = { + name: (None if home is None else SimpleNamespace(chat_id=home[0], name=home[1])) + for name, home in platforms.items() + } + fake = SimpleNamespace( + get_connected_platforms=lambda: list(enums.values()), + get_home_channel=lambda p: homes[p.value], + ) + monkeypatch.setattr("gateway.config.load_gateway_config", lambda: fake) + + def test_handoff_completes_connected_platforms(self, monkeypatch): + """`/handoff ` offers connected platforms, with or without a home channel.""" + self._fake_gateway( + monkeypatch, + { + "telegram": ("123", "Me"), + "discord": None, # no home channel yet -> still listed + }, + ) + + texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff ")} + assert texts == {"telegram", "discord"} + + def test_handoff_filters_by_prefix(self, monkeypatch): + self._fake_gateway( + monkeypatch, + { + "telegram": ("1", "H"), + "signal": ("2", "H"), + }, + ) + + texts = {c.text for c in _completions(SlashCommandCompleter(), "/handoff te")} + assert texts == {"telegram"} + + def test_handoff_no_completion_after_platform_chosen(self, monkeypatch): + self._fake_gateway(monkeypatch, {"telegram": ("1", "H")}) + assert _completions(SlashCommandCompleter(), "/handoff telegram ") == [] + + def test_handoff_completion_swallows_config_errors(self, monkeypatch): + def _boom(): + raise RuntimeError("no gateway config") + + monkeypatch.setattr("gateway.config.load_gateway_config", _boom) + assert _completions(SlashCommandCompleter(), "/handoff ") == [] + + def test_personality_completes_configured_personalities(self): + """`/personality ` lists real personalities, not just `none`. + + Regression: the completer read load_config().agent.personalities, a path + that never exists, so it always came back empty. It must resolve from the + CLI config the runtime actually applies (which ships built-ins). + """ + texts = {c.text for c in _completions(SlashCommandCompleter(), "/personality ")} + assert "none" in texts + assert len(texts) > 1 + # ── Ghost text (SlashCommandAutoSuggest) ──────────────────────────────── diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 3b95b8dceb..c510c4ef23 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -86,6 +86,47 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path): server._sessions.pop(sid, None) +def test_handoff_fail_marks_only_inflight_rows(monkeypatch): + class DbContext: + def __init__(self, db): + self.db = db + + def __enter__(self): + return self.db + + def __exit__(self, *_args): + return False + + class FakeDb: + def __init__(self, state): + self.state = state + self.failed_with = None + + def get_handoff_state(self, _key): + return {"state": self.state, "platform": "telegram", "error": None} + + def fail_handoff(self, _key, error): + self.failed_with = error + self.state = "failed" + + sid = "rt-handoff" + server._sessions[sid] = {"session_key": "stored-handoff"} + try: + pending = FakeDb("pending") + monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(pending)) + result = server._methods["handoff.fail"]("r1", {"session_id": sid, "error": "timed out"}) + assert result["result"] == {"failed": True, "state": "failed"} + assert pending.failed_with == "timed out" + + completed = FakeDb("completed") + monkeypatch.setattr(server, "_session_db", lambda _session: DbContext(completed)) + result = server._methods["handoff.fail"]("r2", {"session_id": sid, "error": "late timeout"}) + assert result["result"] == {"failed": False, "state": "completed"} + assert completed.failed_with is None + finally: + server._sessions.pop(sid, None) + + def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): """Background/preview tasks use ephemeral ids absent from `_sessions`, so the parent workspace is passed explicitly; it must pin instead of clearing back diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 390c31b092..5af8530abc 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,5 +1,6 @@ import atexit import concurrent.futures +import contextlib import contextvars import copy import inspect @@ -1171,6 +1172,34 @@ def _ensure_session_db_row(session: dict) -> None: pass +@contextlib.contextmanager +def _session_db(session: dict): + """Yield the SessionDB that owns this session's row (profile-aware). + + Mirrors :func:`_ensure_session_db_row`: a remote/profile session persists + into its own profile's ``state.db`` (a fresh handle we close on exit); + everything else borrows the shared ``_get_db()`` handle (left open). Yields + None when the db is unavailable. + """ + db, close_db = None, False + profile_home = session.get("profile_home") + if profile_home: + from hermes_state import SessionDB + + try: + db, close_db = SessionDB(db_path=Path(profile_home) / "state.db"), True + except Exception: + logger.debug("failed to open profile db for session", exc_info=True) + else: + db = _get_db() + try: + yield db + finally: + if close_db and db is not None: + with contextlib.suppress(Exception): + db.close() + + def _set_session_cwd(session: dict, cwd: str) -> str: resolved = os.path.abspath(os.path.expanduser(str(cwd))) if not os.path.isdir(resolved): @@ -4193,6 +4222,145 @@ def _(rid, params: dict) -> dict: return _err(rid, 5007, str(e)) +@method("handoff.request") +def _(rid, params: dict) -> dict: + """Queue a handoff of this session to a messaging platform. + + Desktop parity with the CLI ``/handoff`` command: we only write + ``handoff_state='pending'`` onto the persisted session row. The actual + transfer is performed by the separate ``hermes gateway`` process, whose + ``_handoff_watcher`` claims the row, re-binds the session to the platform's + home channel, and forges a synthetic turn. The desktop then polls + ``handoff.state`` for the terminal result. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + if session.get("running"): + return _err( + rid, + 4009, + "session busy — wait for the current turn to finish, then retry the handoff", + ) + + platform_name = (params.get("platform", "") or "").strip().lower() + if not platform_name: + return _err(rid, 4023, "platform required") + + # Validate against the live gateway config — an unconfigured platform or a + # missing home channel would leave the handoff pending forever, so reject + # up front with a clear, actionable message (mirrors cli.py). + try: + from gateway.config import Platform, load_gateway_config + except Exception as e: # pragma: no cover — gateway pkg always ships + return _err(rid, 5021, f"could not load gateway config: {e}") + try: + platform = Platform(platform_name) + except (ValueError, KeyError): + return _err(rid, 4024, f"unknown platform '{platform_name}'") + try: + gw_config = load_gateway_config() + except Exception as e: + return _err(rid, 5021, f"could not load gateway config: {e}") + pcfg = gw_config.platforms.get(platform) + if not pcfg or not pcfg.enabled: + return _err( + rid, + 4025, + f"platform '{platform_name}' is not configured/enabled in the gateway", + ) + home = gw_config.get_home_channel(platform) + if not home or not home.chat_id: + return _err( + rid, + 4026, + f"no home channel configured for {platform_name} — set one with " + "/sethome on the destination chat first", + ) + + # The watcher transfers a persisted DB row, so make sure one exists even + # for a brand-new empty chat (mirrors the CLI's set_session_title stub). + _ensure_session_db_row(session) + + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + try: + if not db.get_session(key): + db.set_session_title(key, f"handoff-{key[:8]}") + ok = db.request_handoff(key, platform_name) + except Exception as e: + return _err(rid, 5007, str(e)) + + if not ok: + return _err( + rid, + 4027, + "session is already in flight for handoff — wait for it to settle, then retry", + ) + return _ok( + rid, + { + "queued": True, + "session_key": key, + "platform": platform_name, + "home_name": home.name, + }, + ) + + +@method("handoff.state") +def _(rid, params: dict) -> dict: + """Poll the handoff state for a session. + + Returns ``{state, platform, error}`` where ``state`` is one of + ``pending|running|completed|failed`` (or empty when no handoff record + exists). Desktop polls this after ``handoff.request``. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + record = db.get_handoff_state(session["session_key"]) + + record = record or {} + return _ok( + rid, + { + "state": record.get("state") or "", + "platform": record.get("platform") or "", + "error": record.get("error") or "", + }, + ) + + +@method("handoff.fail") +def _(rid, params: dict) -> dict: + """Mark an in-flight handoff as failed so the user can retry. + + Desktop calls this when its bounded poll times out. Only pending/running + rows are changed so a late success from the gateway watcher is not clobbered. + """ + session, err = _sess_nowait(params, rid) + if err: + return err + reason = str(params.get("error") or "handoff failed").strip()[:500] + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + record = db.get_handoff_state(key) or {} + state = record.get("state") or "" + if state in {"pending", "running"}: + db.fail_handoff(key, reason) + return _ok(rid, {"failed": True, "state": "failed"}) + + return _ok(rid, {"failed": False, "state": state}) + + @method("session.usage") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid)