diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts index c356fae3af84..c7a4e2f138ac 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -121,3 +121,44 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => { expect(hook.result.current.trigger).toBeNull() }) }) + +describe('useComposerTrigger — free-text slash arguments', () => { + it('keeps a picked /goal command as editable text while retaining subcommand completion', () => { + const editor = mountEditor('/go') + const goal = item('/goal', 'Commands') + const { hook } = mountTrigger(editor, [goal]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(goal)) + + expect(composerPlainText(editor)).toBe('/goal ') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + expect(hook.result.current.trigger).not.toBeNull() + }) + + it('does not seal a multi-word /goal into a chip when the option list runs empty', () => { + const editor = mountEditor('/goal finish the full prompt') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(true) + expect(hook.result.current.commitTypedSlashDirective()).toBe(false) + expect(composerPlainText(editor)).toBe('/goal finish the full prompt') + expect(editor.querySelector('[data-slash-kind]')).toBeNull() + }) + + it('still commits a fully typed finite option as one directive chip', () => { + const editor = mountEditor('/personality creative') + const { hook } = mountTrigger(editor, []) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.slashFreeTextArgStage).toBe(false) + act(() => { + expect(hook.result.current.commitTypedSlashDirective()).toBe(true) + }) + expect(composerPlainText(editor)).toBe('/personality creative ') + expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index 3c22fddb6348..2df218e78f30 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -2,7 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react' import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' -import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' +import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands' import { COMPLETION_ACTIONS, @@ -89,11 +89,16 @@ export function useComposerTrigger({ const before = textBeforeCaret(editor) const found = detectTrigger(before ?? composerPlainText(editor)) - // The arg-stage popover is only useful for commands with an options screen. - // For a no-arg command it would dead-end on "No matches", so drop it — the - // directive is already complete. + // A text-only command has no completion screen once its prose begins. Mixed + // commands such as /goal stay live so their finite subcommands can still be + // suggested, while arbitrary goal text remains valid. + const argumentMode = + found?.kind === '/' && slashArgStage(found.query) + ? desktopSlashCommandArgumentMode(slashCommandToken(found.query)) + : null + const detected = - found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query)) + found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed' ? null : found @@ -134,6 +139,11 @@ export function useComposerTrigger({ // Space/Tab — neither should dead-end on a popover. const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length + const slashFreeTextArgStage = + trigger?.kind === '/' && + slashArgStage(trigger.query) && + ['mixed', 'text'].includes(desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) ?? '') + const closeTrigger = () => { setTrigger(null) setTriggerItems([]) @@ -148,9 +158,16 @@ export function useComposerTrigger({ // the completion list is empty because the arg is already fully typed (the // backend completer drops exact matches). Reuses the chip path via a // synthetic item whose serialized form is the verbatim text. - const commitTypedSlashDirective = () => { + const commitTypedSlashDirective = (): boolean => { if (trigger?.kind !== '/') { - return + return false + } + + // Free prose must stay ordinary contentEditable text. This guard also + // protects against a stale completion result reaching the keydown path + // before refreshTrigger has caught up with the latest DOM input. + if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') { + return false } const text = `/${trigger.query.trimEnd()}` @@ -168,6 +185,8 @@ export function useComposerTrigger({ rawText: text } }) + + return true } const replaceTriggerWithChip = (item: Unstable_TriggerItem) => { @@ -208,15 +227,15 @@ export function useComposerTrigger({ // there's no command invocation for the args to belong to. const command = (item.metadata as { command?: string } | undefined)?.command ?? '' - const expandsToArgs = - trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command) + const argumentMode = desktopSlashCommandArgumentMode(command) + const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null 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 keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text') const finish = () => { draftRef.current = composerPlainText(editor) @@ -288,6 +307,7 @@ export function useComposerTrigger({ refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, triggerItems, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5988d6103707..b5a76f349040 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -304,6 +304,7 @@ export function ChatBar({ refreshTrigger, replaceTriggerWithChip, setTriggerActive, + slashFreeTextArgStage, trigger, triggerActive, triggerItems, @@ -544,7 +545,9 @@ export function ChatBar({ // options step, and an arg option commits the full `/cmd arg` chip. Space // is slash-only (an `@` mention takes a literal space) and gated to a // non-empty query so a bare `/ ` still types a space. - const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) + const acceptOnSpace = + event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim()) && !slashFreeTextArgStage + const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace if (accept) { @@ -579,11 +582,12 @@ export function ChatBar({ slashArgStage(trigger.query) && trigger.query.trim() ) { - event.preventDefault() - triggerKeyConsumedRef.current = true - commitTypedSlashDirective() + if (commitTypedSlashDirective()) { + event.preventDefault() + triggerKeyConsumedRef.current = true - return + return + } } // ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index d8aa1897652d..1fc70dcb5ad5 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { desktopSkinSlashCompletions, + desktopSlashCommandArgumentMode, desktopSlashDescription, desktopSlashUnavailableMessage, filterDesktopCommandsCatalog, @@ -65,7 +66,7 @@ describe('desktop slash command curation', () => { it('routes /pet through the desktop action handler and drops /pets', () => { expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' }) - expect(resolveDesktopCommand('/pet')?.args).toBe(true) + expect(desktopSlashCommandArgumentMode('/pet')).toBe('options') expect(isDesktopSlashSuggestion('/pet')).toBe(true) expect(isDesktopSlashCommand('/pet')).toBe(true) expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' }) @@ -81,14 +82,14 @@ describe('desktop slash command curation', () => { expect(desktopSlashUnavailableMessage('/browser')).toBeNull() expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' }) // Bare /browser expands to its sub-action options in the popover. - expect(resolveDesktopCommand('/browser')?.args).toBe(true) + expect(desktopSlashCommandArgumentMode('/browser')).toBe('options') }) it('routes /compress through the session-compression action', () => { // /compress must be an action (session.compress RPC), not exec: the slash // worker route times out on large sessions (#44456). expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' }) - expect(resolveDesktopCommand('/compress')?.args).toBe(true) + expect(desktopSlashCommandArgumentMode('/compress')).toBe('text') expect(isDesktopSlashCommand('/compress')).toBe(true) expect(isDesktopSlashSuggestion('/compress')).toBe(true) expect(desktopSlashUnavailableMessage('/compress')).toBeNull() @@ -144,12 +145,13 @@ describe('desktop slash command curation', () => { } }) - it('keeps /goal arg text editable instead of sealing it into a chip', () => { - // /goal takes free prose (the goal itself) plus subcommands. Without - // args:true, Space after the command name committed a sealed directive - // chip and the goal text rendered awkwardly after a pill. - expect(resolveDesktopCommand('/goal')?.surface).toEqual({ kind: 'exec' }) - expect(resolveDesktopCommand('/goal')?.args).toBe(true) + it('distinguishes free prose from finite slash option lists', () => { + expect(desktopSlashCommandArgumentMode('/goal')).toBe('mixed') + expect(desktopSlashCommandArgumentMode('/steer')).toBe('text') + expect(desktopSlashCommandArgumentMode('/queue')).toBe('text') + expect(desktopSlashCommandArgumentMode('/personality')).toBe('options') + expect(desktopSlashCommandArgumentMode('/handoff')).toBe('options') + expect(desktopSlashCommandArgumentMode('/version')).toBeNull() }) it('routes /journey (and aliases) to the memory graph overlay action', () => { diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 5fccef48c719..81e4a694c015 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -91,6 +91,16 @@ export interface SlashCommandBuildCtx { sessionId: string } +/** + * How arguments behave in the Desktop composer. + * + * - `options` → a finite completion list; picking or fully typing an option may + * commit the complete directive as a chip. + * - `text` → arbitrary prose; the command and its argument stay editable. + * - `mixed` → offers subcommand completions but also accepts arbitrary prose. + */ +export type DesktopSlashArgumentMode = 'mixed' | 'options' | 'text' + export interface DesktopCommandSpec { /** Canonical command, leading slash included (e.g. `/resume`). */ name: string @@ -104,12 +114,8 @@ export interface DesktopCommandSpec { * 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 + /** Composer behavior for text following the command token. */ + argumentMode?: DesktopSlashArgumentMode } const exec = (): DesktopCommandSurface => ({ kind: 'exec' }) @@ -151,17 +157,22 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), - args: true + argumentMode: 'options' }, { 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: '/skin', + description: 'Switch desktop theme or cycle to the next one', + surface: action('skin'), + argumentMode: 'options' + }, + { name: '/title', description: 'Rename the current session', surface: action('title'), argumentMode: 'text' }, { name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') }, { name: '/browser', description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)', surface: action('browser'), - args: true + argumentMode: 'options' }, { name: '/journey', @@ -177,7 +188,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ description: 'Resume a saved session', aliases: ['/sessions', '/switch'], surface: picker('session'), - args: true + argumentMode: 'options' }, // Backend-executed commands that render useful inline output. @@ -194,7 +205,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ name: '/approvals', description: 'Show or set approval mode [manual|smart|off]', surface: exec(), - args: true + argumentMode: 'options' }, { name: '/agents', @@ -202,7 +213,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ aliases: ['/tasks'], surface: exec() }, - { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() }, + { + name: '/background', + description: 'Run a prompt in the background', + aliases: ['/bg', '/btw'], + surface: exec(), + argumentMode: 'text' + }, // /compress must be an action (session.compress RPC), not exec: the slash // worker route times out on large sessions (30s WS / 45s pipe) before the // LLM summarise call finishes, then command.dispatch surfaces a bogus @@ -212,16 +229,26 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ description: 'Compress this conversation context', aliases: ['/compact'], surface: action('compress'), - args: true + argumentMode: 'text' }, { name: '/debug', description: 'Create a debug report', surface: exec() }, - { name: '/goal', description: 'Manage the standing goal for this session', surface: exec(), args: true }, - { name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true }, + { + name: '/goal', + description: 'Manage the standing goal for this session', + surface: exec(), + argumentMode: 'mixed' + }, + { + name: '/personality', + description: 'Switch personality for this session', + surface: exec(), + argumentMode: 'options' + }, { name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), - args: true + argumentMode: 'options' }, { name: '/hatch', @@ -229,7 +256,13 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ aliases: ['/generate-pet'], surface: action('hatch') }, - { name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() }, + { + name: '/queue', + description: 'Queue a prompt for the next turn', + aliases: ['/q'], + surface: exec(), + argumentMode: 'text' + }, { name: '/retry', description: 'Retry the last user message', surface: exec() }, { name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() }, { @@ -242,9 +275,19 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ description: 'Show current session status', surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId })) }, - { name: '/steer', description: 'Steer the current run after the next tool call', surface: exec(), args: true }, + { + name: '/steer', + description: 'Steer the current run after the next tool call', + surface: exec(), + argumentMode: 'text' + }, { 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: '/tools', + description: 'List or toggle tools available to the agent', + surface: exec(), + argumentMode: 'options' + }, { 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() }, @@ -433,13 +476,8 @@ export function desktopSlashDescription(command: string, fallback = ''): string return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || 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 desktopSlashCommandArgumentMode(command: string): DesktopSlashArgumentMode | null { + return resolveDesktopCommand(command)?.argumentMode ?? null } export function desktopSkinSlashCompletions(