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),
);