desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)
* desktop: surface /tools, /save, /personality and fix /help skill count Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via the existing slash.exec → command.dispatch fallback. The backend gateway already accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the skill list), so no backend change is required. Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs. Previously the /help footer echoed the unfiltered backend total — e.g. "60 skill commands available" while only ~29 actually appeared in the rendered list, because the desktop hides terminal-only, picker-owned, and advanced commands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: keep slash popover live while typing args The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment the user typed a space after a slash command, so the popover never showed arg completions for `/personality`, `/tools`, etc. — even though the backend's `complete.slash` already returns them with a `replace_from` indicator. Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@` keeps the strict no-space behavior. Restrict the slash command name to `[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger the popover. Rewrite arg-completion items in useSlashCompletions to insert the full `/personality alice` token instead of stranding `/alice`: when `replace_from` is past the command base, prepend the existing prefix to each item's text so the chip serializer produces a coherent replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * cli: complete toolset names after /tools enable|disable SlashCommandCompleter previously only auto-derived the first subcommand level from args_hint, so `/tools enable <tab>` yielded nothing — the user had to remember every toolset key (web, file, spotify, …) and every MCP server prefix. Add `_tools_completions` that handles both stages: subcommand (list|disable|enable) and tool name. Filter by current enable state so `/tools enable <tab>` only offers disabled toolsets and `/tools disable <tab>` only offers enabled ones — no point suggesting a no-op. MCP server prefixes (server:) come from the saved mcp_servers config; per-tool completion under a server would require runtime MCP introspection and is left as follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * desktop: registry-driven slash commands with first-class pickers Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table that drives popover suggestions, per-type composer pills, and execution. - /resume, /sessions, /switch: inline session completions (like /skin) plus a "Browse all sessions…" entry that opens a dedicated session picker overlay - /handoff: inline platform completion + handoff.request/handoff.state gateway bridge so desktop reaches CLI parity - colored per-type pills (command/skill/theme) in the composer - strip ANSI and fix width/alignment of slash output in the chat panel * desktop: fold repeated slash session/output boilerplate into one helper runExec, /title, /help and the unavailable case each re-derived the same ensure-session → bail-with-notify → build-renderSlashOutput dance. withSlashOutput() returns {sessionId, render} or null, so each handler is a two-line resolve instead of an eight-line preamble. * desktop: keep backend meta on slash arg completions Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff <platform>) were having their meta overwritten with the parent command's registry description: desktopSlashDescription("/personality none") canonicalizes back to /personality and returns its blurb. Skip the lookup for arg rows so the backend's own display_meta ("clear personality overlay", etc.) survives. * cli: list real personalities in /personality completion _personality_completions resolved load_config().agent.personalities — but that schema has no agent.personalities key, so completion always returned just `none` even though the runtime (load_cli_config().agent.personalities) ships a dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the command actually applies, so `/personality ` surfaces the real options. * desktop: expand bare arg-commands to their options on pick Picking a command like /personality from the slash popover committed it immediately instead of advancing to its argument list. Mark arg-taking commands (/skin, /resume, /handoff, /personality, /tools) in the registry and, when one is picked bare, insert "/cmd " as plain text and re-open the popover on its inline options — mirroring typing "/cmd " by hand. Arg picks (serialized text already contains a space) still commit a single pill. Also realign trigger-popover loading test with the redesigned popover (the /help empty-state hint shows when resolved, not while the spinner is up); the merge from main reintroduced the pre-redesign expectation. * tui_gateway: fold session-db close into a context manager Both handoff RPCs repeated the same `db, close_db = _session_db_handle()` + `finally: if close_db: db.close()` dance. Turn the helper into a `_session_db` contextmanager that owns the close, so callers just `with _session_db(session) as db:`. * desktop: unblock handoff retries and exact resume ids Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
615ad97928
commit
3ffbdfbcc0
@@ -63,7 +63,7 @@ export function directiveIconSvg(type: string) {
|
||||
return `<svg ${SVG_ATTRS} class="size-3 shrink-0 opacity-80">${inner}</svg>`
|
||||
}
|
||||
|
||||
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<SlashChipKind, string[]> = {
|
||||
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<SlashChipKind, string> = {
|
||||
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 }) => (
|
||||
<svg
|
||||
className="size-3 shrink-0 opacity-80"
|
||||
|
||||
@@ -929,22 +929,42 @@ const SystemMessage: FC = () => {
|
||||
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 (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/60"
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
|
||||
{multiline ? (
|
||||
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
) : (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
|
||||
</>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const multiline = text.includes('\n')
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="max-w-[min(86%,44rem)] self-center px-2 py-0.5 text-center text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
className={cn(
|
||||
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55',
|
||||
multiline ? 'text-left' : 'text-center'
|
||||
)}
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop>
|
||||
<CommandInput
|
||||
onValueChange={setSearch}
|
||||
placeholder={t.commandCenter.searchPlaceholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
<CommandGroup
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
heading={t.commandCenter.sections.sessions}
|
||||
>
|
||||
{sessions.map(session => {
|
||||
const title = sessionTitle(session)
|
||||
const preview = session.preview?.trim()
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2.5"
|
||||
key={session.id}
|
||||
onSelect={() => {
|
||||
onResume(session.id)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
value={`${title} ${preview ?? ''} ${session.id}`}
|
||||
>
|
||||
<MessageCircle className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col leading-snug">
|
||||
<span className="truncate">{title}</span>
|
||||
{preview ? (
|
||||
<span className="truncate text-xs text-muted-foreground/70">{preview}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0 text-foreground',
|
||||
session.id !== activeStoredSessionId && 'invisible'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user