fix(ui): ensure autocomplete allows immediate execution and subcommand disclosure (Team Consensus)

This commit is contained in:
Keith Guerin
2026-03-26 15:49:08 -07:00
parent 973092df50
commit c498284996
6 changed files with 239 additions and 54 deletions
@@ -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(
<InputPrompt {...props} />,
{
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(
<InputPrompt {...props} />,
{
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();
});
@@ -1082,16 +1082,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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());
@@ -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 () => {
@@ -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 (
@@ -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({
+63 -28
View File
@@ -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),
);