Restore /goal as a single desktop slash command

The desktop composer had treated /goal lifecycle arguments as standalone slash-command rows. That made the UI look like /goal had second-level commands even though the CLI contract is one /goal command with arguments.

Constraint: Keep /goal status/pause/resume/complete/clear usable as CLI arguments.

Rejected: Keep pseudo subcommands in the picker | it contradicts the slash command model and confuses objective entry.

Confidence: high

Scope-risk: narrow

Tested: cd desktop && bun run test -- --run src/components/chat/composerUtils.test.ts src/__tests__/pages.test.tsx src/pages/EmptySession.test.tsx

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run build
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 01:59:06 +08:00
parent 7dc0b59784
commit d76c09c3d6
5 changed files with 43 additions and 40 deletions

View File

@ -159,6 +159,22 @@ describe('Content-only pages render without errors', () => {
expect(screen.queryByText('/internal-only')).not.toBeInTheDocument()
})
it('EmptySession shows /goal as one command with argument hints, not pseudo subcommands', async () => {
vi.mocked(skillsApi.list).mockResolvedValueOnce({ skills: [] })
render(<EmptySession />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '/goal', selectionStart: 5 },
})
expect(await screen.findAllByText('/goal')).toHaveLength(2)
expect(screen.getByText('[status|pause|resume|complete|clear|--tokens <budget>|<objective>]')).toBeInTheDocument()
expect(screen.getByText('Create or manage an autonomous completion goal')).toBeInTheDocument()
expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
})
it('EmptySession renders mascot and composer', async () => {
let container!: HTMLElement
await act(async () => {

View File

@ -887,12 +887,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="flex min-w-0 max-w-[48%] shrink-0 items-baseline gap-1.5">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
/{command.name}
</span>
{command.argumentHint ? (
<span className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{command.argumentHint}
</span>
) : null}

View File

@ -18,9 +18,9 @@ describe('composerUtils', () => {
expect(findSlashToken('/ review', 8)).toBeNull()
})
it('keeps /goal argument completion active for one subcommand token', () => {
expect(findSlashToken('/goal ', 6)).toEqual({ start: 0, filter: 'goal ' })
expect(findSlashToken('/goal sta', 9)).toEqual({ start: 0, filter: 'goal sta' })
it('closes slash completion once /goal arguments start', () => {
expect(findSlashToken('/goal ', 6)).toBeNull()
expect(findSlashToken('/goal sta', 9)).toBeNull()
expect(findSlashToken('/goal build app', 15)).toBeNull()
})
@ -82,28 +82,22 @@ describe('composerUtils', () => {
)
})
it('suggests /goal subcommands after the /goal argument separator', () => {
expect(
filterSlashCommands(mergeSlashCommands([]), 'goal ').map((command) => command.name),
).toEqual([
'goal status',
'goal pause',
'goal resume',
'goal complete',
'goal clear',
'goal --tokens',
])
it('keeps /goal as a single command with argument hints instead of pseudo subcommands', () => {
const commands = filterSlashCommands(mergeSlashCommands([]), 'goal')
expect(
filterSlashCommands(mergeSlashCommands([]), 'goal s').map((command) => command.name),
).toEqual(['goal status'])
commands.map((command) => command.name),
).toEqual(['goal'])
expect(commands[0]).toMatchObject({
description: 'Create or manage an autonomous completion goal',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
})
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal status')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('goal --tokens')
})
it('replaces a /goal subcommand fragment with the selected subcommand', () => {
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toEqual({
value: '/goal status ',
cursorPos: 13,
})
it('does not replace /goal arguments as slash command fragments', () => {
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toBeNull()
})
it('ranks slash command name matches before broad description matches', () => {

View File

@ -19,19 +19,6 @@ export const SLASH_COMMAND_ALIASES = [
{ name: 'plugins', target: 'plugin' },
] as const
export const GOAL_SLASH_SUBCOMMANDS = [
{ name: 'goal status', description: 'Show the current goal status' },
{ name: 'goal pause', description: 'Pause the active goal loop' },
{ name: 'goal resume', description: 'Resume the active goal loop' },
{ name: 'goal complete', description: 'Mark the active goal complete' },
{ name: 'goal clear', description: 'Clear the active goal' },
{
name: 'goal --tokens',
description: 'Create a goal with a token budget',
argumentHint: '<budget> <objective>',
},
] as const
export const FALLBACK_SLASH_COMMANDS = [
...PANEL_SLASH_COMMANDS,
...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })),
@ -42,7 +29,6 @@ export const FALLBACK_SLASH_COMMANDS = [
description: 'Create or manage an autonomous completion goal',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
},
...GOAL_SLASH_SUBCOMMANDS,
{ name: 'review', description: 'Review code changes' },
{ name: 'commit', description: 'Create a git commit' },
{ name: 'pr', description: 'Create a pull request' },
@ -168,7 +154,7 @@ export function findSlashTrigger(value: string, cursorPos: number): SlashTrigger
const filter = textBeforeCursor.slice(slashPos + 1)
if (filter.includes('\n')) return null
if (/\s/.test(filter) && !/^goal\s+[^\s]*$/i.test(filter)) return null
if (/\s/.test(filter)) return null
return { slashPos, filter }
}

View File

@ -640,7 +640,14 @@ export function EmptySession() {
index === slashSelectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
{command.argumentHint ? (
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{command.argumentHint}
</span>
) : null}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
</button>
))}