fix(desktop): keep free-text slash arguments editable (#72768)
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Check no committed infographics (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Waiting to run
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build, Test, and Publish / merge (push) Blocked by required conditions
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions

This commit is contained in:
Gille 2026-07-27 11:47:17 -06:00 committed by GitHub
parent 2b0fb72aca
commit c2e45b555f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 156 additions and 51 deletions

View file

@ -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')
})
})

View file

@ -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,

View file

@ -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

View file

@ -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', () => {

View file

@ -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 `/<cmd> ` 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(