Expose goal subcommands in desktop composer

The CLI supports /goal status, pause, resume, complete, clear, and --tokens as arguments, but the desktop composer only showed the top-level /goal entry and closed completion after the space. Users could run the commands if they knew them, yet the UI made them look unavailable.

Constraint: Keep CLI semantics unchanged; this is a desktop discovery and completion fix only

Rejected: Add separate CLI slash commands for every goal subcommand | would diverge from the actual command surface

Confidence: high

Scope-risk: narrow

Directive: Keep /goal subcommands as argument completions unless the CLI command model gains first-class nested slash commands

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

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx

Tested: cd desktop && bun run lint

Not-tested: live desktop manual interaction after restarting the app
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 00:21:06 +08:00
parent 5ed1081252
commit e77dd6d3cb
2 changed files with 52 additions and 21 deletions

View File

@ -18,6 +18,12 @@ 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' })
expect(findSlashToken('/goal build app', 15)).toBeNull()
})
it('inserts a slash trigger without appending a trailing space', () => {
expect(insertSlashTrigger('', 0)).toEqual({ value: '/', cursorPos: 1 })
expect(insertSlashTrigger('hello', 5)).toEqual({ value: 'hello /', cursorPos: 7 })
@ -70,12 +76,36 @@ describe('composerUtils', () => {
{
name: 'goal',
description: 'Create or manage an autonomous completion goal',
argumentHint: '<objective>|status|pause|resume|clear|complete',
argumentHint: '[status|pause|resume|complete|clear|--tokens <budget>|<objective>]',
},
]),
)
})
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',
])
expect(
filterSlashCommands(mergeSlashCommands([]), 'goal s').map((command) => command.name),
).toEqual(['goal status'])
})
it('replaces a /goal subcommand fragment with the selected subcommand', () => {
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toEqual({
value: '/goal status ',
cursorPos: 13,
})
})
it('ranks slash command name matches before broad description matches', () => {
expect(
filterSlashCommands([

View File

@ -19,6 +19,19 @@ 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 })),
@ -27,8 +40,9 @@ export const FALLBACK_SLASH_COMMANDS = [
{
name: 'goal',
description: 'Create or manage an autonomous completion goal',
argumentHint: '<objective>|status|pause|resume|clear|complete',
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' },
@ -127,8 +141,8 @@ export function filterSlashCommands(
commands: ReadonlyArray<SlashCommandOption>,
filter: string,
): SlashCommandOption[] {
const normalized = filter.trim().toLowerCase()
if (!normalized) return [...commands]
const normalized = filter.toLowerCase()
if (!normalized.trim()) return [...commands]
return commands
.map((command, index) => ({
@ -148,26 +162,13 @@ export type SlashTrigger = {
export function findSlashTrigger(value: string, cursorPos: number): SlashTrigger | null {
const textBeforeCursor = value.slice(0, cursorPos)
let slashPos = -1
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
const ch = textBeforeCursor[i]!
if (ch === '/') {
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
slashPos = i
break
}
break
}
if (/\s/.test(ch)) {
break
}
}
const slashPos = textBeforeCursor.lastIndexOf('/')
if (slashPos < 0) return null
if (slashPos > 0 && !/\s/.test(textBeforeCursor[slashPos - 1]!)) return null
const filter = textBeforeCursor.slice(slashPos + 1)
if (/\s/.test(filter)) return null
if (filter.includes('\n')) return null
if (/\s/.test(filter) && !/^goal\s+[^\s]*$/i.test(filter)) return null
return { slashPos, filter }
}