feat: auto-execute on slash command completion functions (#14584)

This commit is contained in:
Jack Wotherspoon
2025-12-08 16:32:39 -05:00
committed by GitHub
parent 17b5b40765
commit 89570aef06
9 changed files with 197 additions and 8 deletions

View File

@@ -157,7 +157,7 @@ const resumeCommand: SlashCommand = {
description:
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
autoExecute: true,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
@@ -241,7 +241,7 @@ const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
autoExecute: true,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {

View File

@@ -228,6 +228,7 @@ const enableCommand: SlashCommand = {
name: 'enable',
description: 'Enable a hook by name',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: enableAction,
completion: completeHookNames,
};
@@ -236,6 +237,7 @@ const disableCommand: SlashCommand = {
name: 'disable',
description: 'Disable a hook by name',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: disableAction,
completion: completeHookNames,
};

View File

@@ -31,7 +31,7 @@ const authCommand: SlashCommand = {
name: 'auth',
description: 'Authenticate with an OAuth-enabled MCP server',
kind: CommandKind.BUILT_IN,
autoExecute: false,
autoExecute: true,
action: async (
context: CommandContext,
args: string,

View File

@@ -189,7 +189,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
kind: CommandKind.BUILT_IN,
autoExecute: false,
autoExecute: true,
action: restoreAction,
completion,
};

View File

@@ -196,6 +196,8 @@ describe('InputPrompt', () => {
completionStart: -1,
completionEnd: -1,
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
isArgumentCompletion: false,
leafCommand: null,
},
getCompletedText: vi.fn().mockReturnValue(null),
};
@@ -807,6 +809,8 @@ describe('InputPrompt', () => {
completionStart: 1,
completionEnd: 3, // "/ab" -> start at 1, end at 3
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: false,
leafCommand: null,
},
});
@@ -952,6 +956,158 @@ describe('InputPrompt', () => {
unmount();
});
it('should auto-execute argument completion when command has autoExecute: true', async () => {
// Simulates: /mcp auth <server> where user selects a server from completions
const authCommand: SlashCommand = {
name: 'auth',
kind: CommandKind.BUILT_IN,
description: 'Authenticate with MCP server',
action: vi.fn(),
autoExecute: true,
completion: vi.fn().mockResolvedValue(['server1', 'server2']),
};
const suggestion = { label: 'server1', value: 'server1' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(authCommand),
getCompletedText: vi.fn().mockReturnValue('/mcp auth server1'),
slashCompletionRange: {
completionStart: 10,
completionEnd: 10,
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: true,
leafCommand: authCommand,
},
});
props.buffer.setText('/mcp auth ');
props.buffer.lines = ['/mcp auth '];
props.buffer.cursor = [0, 10];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should auto-execute with the completed command
expect(props.onSubmit).toHaveBeenCalledWith('/mcp auth server1');
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete argument completion when command has autoExecute: false', async () => {
// Simulates: /extensions enable <ext> where multi-arg completions should NOT auto-execute
const enableCommand: SlashCommand = {
name: 'enable',
kind: CommandKind.BUILT_IN,
description: 'Enable an extension',
action: vi.fn(),
autoExecute: false,
completion: vi.fn().mockResolvedValue(['ext1 --scope user']),
};
const suggestion = {
label: 'ext1 --scope user',
value: 'ext1 --scope user',
};
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(enableCommand),
getCompletedText: vi
.fn()
.mockReturnValue('/extensions enable ext1 --scope user'),
slashCompletionRange: {
completionStart: 19,
completionEnd: 19,
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: true,
leafCommand: enableCommand,
},
});
props.buffer.setText('/extensions enable ');
props.buffer.lines = ['/extensions enable '];
props.buffer.cursor = [0, 19];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete (not execute) to allow user to modify
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete command name even with autoExecute: true if command has completion function', async () => {
// Simulates: /chat resu -> should NOT auto-execute, should autocomplete to show arg completions
const resumeCommand: SlashCommand = {
name: 'resume',
kind: CommandKind.BUILT_IN,
description: 'Resume a conversation',
action: vi.fn(),
autoExecute: true,
completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
};
const suggestion = { label: 'resume', value: 'resume' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(resumeCommand),
getCompletedText: vi.fn().mockReturnValue('/chat resume'),
slashCompletionRange: {
completionStart: 6,
completionEnd: 10,
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: false,
leafCommand: null,
},
});
props.buffer.setText('/chat resu');
props.buffer.lines = ['/chat resu'];
props.buffer.cursor = [0, 10];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete to allow selecting an argument, NOT auto-execute
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,

View File

@@ -621,16 +621,41 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const isEnterKey = key.name === 'return' && !key.ctrl;
if (isEnterKey && buffer.text.startsWith('/')) {
const command = completion.getCommandFromSuggestion(suggestion);
const { isArgumentCompletion, leafCommand } =
completion.slashCompletionRange;
if (command && isAutoExecutableCommand(command)) {
if (
isArgumentCompletion &&
isAutoExecutableCommand(leafCommand)
) {
// isArgumentCompletion guarantees leafCommand exists
const completedText = completion.getCompletedText(suggestion);
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
}
} else if (!isArgumentCompletion) {
// Existing logic for command name completion
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);
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
}
}
}
}

View File

@@ -51,6 +51,8 @@ export interface UseCommandCompletionReturn {
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
isArgumentCompletion: boolean;
leafCommand: SlashCommand | null;
};
getCompletedText: (suggestion: Suggestion) => string | null;
}

View File

@@ -419,6 +419,8 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
isArgumentCompletion: boolean;
leafCommand: SlashCommand | null;
} {
const {
enabled,
@@ -568,5 +570,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
completionEnd,
getCommandFromSuggestion: (suggestion: Suggestion) =>
getCommandFromSuggestion(suggestion, parserResult),
isArgumentCompletion: parserResult.isArgumentCompletion,
leafCommand: parserResult.leafCommand,
};
}

View File

@@ -225,7 +225,7 @@ export const getUrlOpenCommand = (): string => {
* @returns true if the command should auto-execute on Enter
*/
export function isAutoExecutableCommand(
command: SlashCommand | undefined,
command: SlashCommand | undefined | null,
): boolean {
if (!command) {
return false;