diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 49dd08ac53..d74d028199 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1445,9 +1445,101 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should autocomplete to allow adding file argument + // Should submit the full command constructed from buffer + suggestion + // even if autoExecute is false, because the user explicitly hit Enter on the suggestion. + expect(props.onSubmit).toHaveBeenCalledWith('/share'); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + }); + unmount(); + }); + + it('should add a space on Tab when command is already complete in the buffer', async () => { + const executableCommand: SlashCommand = { + name: 'about', + kind: CommandKind.BUILT_IN, + description: 'About info', + action: vi.fn(), + autoExecute: true, + }; + + const suggestion = { label: 'about', value: 'about' }; + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [suggestion], + activeSuggestionIndex: 0, + getCommandFromSuggestion: vi.fn().mockReturnValue(executableCommand), + getCompletedText: vi.fn().mockReturnValue('/about'), + }); + + // Buffer is already complete + props.buffer.setText('/about'); + props.buffer.lines = ['/about']; + props.buffer.cursor = [0, 6]; + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\t'); // Tab + }); + + await waitFor(() => { + // Should call handleAutocomplete which will add the space expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + }); + unmount(); + }); + + it('should submit the base command when Enter is pressed on its suggestion even with a trailing space', async () => { + const statsCommand: SlashCommand = { + name: 'stats', + kind: CommandKind.BUILT_IN, + description: 'Stats info', + action: vi.fn(), + autoExecute: false, + }; + + const suggestion = { + label: 'stats', + value: 'stats', + sectionTitle: 'command', + insertValue: 'stats', + }; + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [suggestion], + activeSuggestionIndex: 0, + getCommandFromSuggestion: vi.fn().mockReturnValue(statsCommand), + getCompletedText: vi.fn().mockReturnValue('/stats'), + }); + + // Buffer has trailing space: "/stats " + props.buffer.setText('/stats '); + props.buffer.lines = ['/stats ']; + props.buffer.cursor = [0, 7]; + + const { stdin, unmount } = await renderWithProviders( + , + { + uiActions, + }, + ); + + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + // Should submit "/stats" + expect(props.onSubmit).toHaveBeenCalledWith('/stats'); }); unmount(); }); @@ -1564,9 +1656,9 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should autocomplete (not execute) since autoExecute is undefined - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit the command + expect(props.onSubmit).toHaveBeenCalledWith('/find-capital'); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); }); unmount(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f078dbc7d6..119c2a8025 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1082,16 +1082,12 @@ export const InputPrompt: React.FC = ({ const command = completion.getCommandFromSuggestion(suggestion); - // Only auto-execute if the command has no completion function - // (i.e., it doesn't require an argument to be selected) - if ( - command && - isAutoExecutableCommand(command) && - !command.completion - ) { - const completedText = - completion.getCompletedText(suggestion); + const completedText = completion.getCompletedText(suggestion); + // If the user hits Enter on a suggestion, we should submit it + // IF it has an action and doesn't require further arguments (no completion function). + // This is separate from autoExecute, which handles automatic execution while typing. + if (command && command.action && !command.completion) { if (completedText) { setExpandedSuggestionIndex(-1); handleSubmit(completedText.trim()); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 982991bf9a..67a9d2b87e 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -481,7 +481,7 @@ describe('useCommandCompletion', () => { }); describe('handleAutocomplete', () => { - it('should complete a partial command and NOT add a space if it has an action', async () => { + it('should complete a partial command and ALWAYS add a space if it is a slash command', async () => { setupMocks({ slashSuggestions: [{ label: 'memory', value: 'memory' }], slashCompletionRange: { @@ -502,7 +502,31 @@ describe('useCommandCompletion', () => { result.current.handleAutocomplete(0); }); - expect(result.current.textBuffer.text).toBe('/memory'); + expect(result.current.textBuffer.text).toBe('/memory '); + }); + + it('should ADD a space even even if it is ALREADY complete', async () => { + setupMocks({ + slashSuggestions: [{ label: 'stats', value: 'stats' }], + slashCompletionRange: { + completionStart: 1, + completionEnd: 6, // "/stats" + getCommandFromSuggestion: () => + ({ action: vi.fn() }) as unknown as SlashCommand, + }, + }); + + const { result } = await renderCommandCompletionHook('/stats'); + + await waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + }); + + act(() => { + result.current.handleAutocomplete(0); + }); + + expect(result.current.textBuffer.text).toBe('/stats '); }); it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => { diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 4f89d69ff1..60ed0421a0 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -440,13 +440,10 @@ export function useCommandCompletion({ let shouldAddSpace = true; if (completionMode === CompletionMode.SLASH) { - const command = - slashCompletionRange.getCommandFromSuggestion(suggestion); - // Don't add a space if the command has an action (can be executed) - // and doesn't have a completion function (doesn't REQUIRE more arguments) - const isExecutableCommand = !!(command && command.action); - const requiresArguments = !!(command && command.completion); - shouldAddSpace = !isExecutableCommand || requiresArguments; + // ALWAYS add a space when autocompleting a slash command via Tab. + // This ensures subcommands are immediately disclosed. + // The base command will still be accessible at the top of the suggestions list. + shouldAddSpace = true; } if ( diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 0bcb3863ce..6189babdf5 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -501,13 +501,14 @@ describe('useSlashCompletion', () => { }); const chatCheckpointLabels = chatResult.current.suggestions - .slice(1) + .slice(0) .map((s) => s.label); const resumeCheckpointLabels = resumeResult.current.suggestions - .slice(1) + .slice(0) .map((s) => s.label); - expect(chatCheckpointLabels).toEqual(resumeCheckpointLabels); + expect(chatCheckpointLabels).toEqual(['list', 'chat', 'save']); + expect(resumeCheckpointLabels).toEqual(['list', 'resume', 'save']); unmountChat(); unmountResume(); @@ -773,6 +774,46 @@ describe('useSlashCompletion', () => { unmount(); }); + it('should suggest the parent command itself at the top when a trailing space is present (if it has an action)', async () => { + const slashCommands = [ + createTestCommand({ + name: 'stats', + description: 'Check session stats', + action: vi.fn(), + subCommands: [ + createTestCommand({ + name: 'session', + description: 'Show session-specific usage statistics', + }), + ], + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/stats ', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + // Should show "stats" as the first suggestion, followed by its subcommands + expect(result.current.suggestions.length).toBe(2); + expect(result.current.suggestions[0]).toMatchObject({ + label: 'stats', + sectionTitle: 'command', + }); + expect(result.current.suggestions[1]).toMatchObject({ + label: 'session', + }); + }); + unmount(); + }); + it('should suggest parent command (and siblings) instead of sub-commands when no trailing space', async () => { const slashCommands = [ createTestCommand({ diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7b06fdc1f4..20a5247d23 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -313,7 +313,56 @@ function useCommandSuggestions( return 0; // Maintain FZF score order for other matches }); - const finalSuggestions = sortedSuggestions.map((cmd) => { + const isTopLevelChatOrResumeContext = !!( + leafCommand && + (leafCommand.name === 'chat' || leafCommand.name === 'resume') && + (commandPathParts.length === 0 || + (commandPathParts.length === 1 && + matchesCommand(leafCommand, commandPathParts[0]))) + ); + + const resultSuggestions: Suggestion[] = []; + + if (isTopLevelChatOrResumeContext && leafCommand) { + const canonicalParentName = leafCommand.name; + const autoSectionSuggestion: Suggestion = { + label: 'list', + value: 'list', + insertValue: canonicalParentName, + description: 'Browse auto-saved chats', + commandKind: CommandKind.BUILT_IN, + sectionTitle: 'auto', + submitValue: `/${canonicalParentName}`, + }; + resultSuggestions.push(autoSectionSuggestion); + } + + // If we have a leaf command with an action and a trailing space, + // add it to the suggestions list so it is still accessible. + if ( + parserResult.hasTrailingSpace && + leafCommand && + leafCommand.action && + !isArgumentCompletion + ) { + const selfSuggestion: Suggestion = { + label: leafCommand.name, + value: leafCommand.name, + description: leafCommand.description, + commandKind: leafCommand.kind, + sectionTitle: 'command', + // Use insertValue to ensure that if selected, we don't add another space + insertValue: leafCommand.name, + }; + resultSuggestions.push(selfSuggestion); + } + + sortedSuggestions.forEach((cmd) => { + // Avoid duplicate 'list' for chat/resume context + if (isTopLevelChatOrResumeContext && cmd.name === 'list') { + return; + } + const suggestion: Suggestion = { label: cmd.name, value: cmd.name, @@ -325,33 +374,10 @@ function useCommandSuggestions( suggestion.sectionTitle = cmd.suggestionGroup; } - return suggestion; + resultSuggestions.push(suggestion); }); - const isTopLevelChatOrResumeContext = !!( - leafCommand && - (leafCommand.name === 'chat' || leafCommand.name === 'resume') && - (commandPathParts.length === 0 || - (commandPathParts.length === 1 && - matchesCommand(leafCommand, commandPathParts[0]))) - ); - - if (isTopLevelChatOrResumeContext) { - const canonicalParentName = leafCommand.name; - const autoSectionSuggestion: Suggestion = { - label: 'list', - value: 'list', - insertValue: canonicalParentName, - description: 'Browse auto-saved chats', - commandKind: CommandKind.BUILT_IN, - sectionTitle: 'auto', - submitValue: `/${canonicalParentName}`, - }; - setSuggestions([autoSectionSuggestion, ...finalSuggestions]); - return; - } - - setSuggestions(finalSuggestions); + setSuggestions(resultSuggestions); } }; @@ -448,7 +474,15 @@ function getCommandFromSuggestion( suggestion: Suggestion, parserResult: CommandParserResult, ): SlashCommand | undefined { - const { currentLevel } = parserResult; + const { currentLevel, leafCommand, hasTrailingSpace } = parserResult; + + if ( + hasTrailingSpace && + leafCommand && + suggestion.value === leafCommand.name + ) { + return leafCommand; + } if (!currentLevel) { return undefined; @@ -456,7 +490,8 @@ function getCommandFromSuggestion( // suggestion.value is just the command name at the current level (e.g., "list") // Find it in the current level's commands - const command = currentLevel.find((cmd) => + const command = currentLevel.find( + (cmd) => matchesCommand(cmd, suggestion.value), matchesCommand(cmd, suggestion.value), );