From 12a8a16c7815695b4e0751a65c1091fdf9187240 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 5 Dec 2025 11:25:48 -0500 Subject: [PATCH 1/2] feat: auto-execute on completion functions --- packages/cli/src/ui/commands/chatCommand.ts | 4 +- .../cli/src/ui/commands/extensionsCommand.ts | 4 +- packages/cli/src/ui/commands/hooksCommand.ts | 2 + packages/cli/src/ui/commands/mcpCommand.ts | 2 +- .../cli/src/ui/commands/restoreCommand.ts | 2 +- .../src/ui/components/InputPrompt.test.tsx | 156 ++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 31 +++- .../cli/src/ui/hooks/useCommandCompletion.tsx | 2 + .../cli/src/ui/hooks/useSlashCompletion.ts | 4 + packages/cli/src/ui/utils/commandUtils.ts | 2 +- 10 files changed, 199 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index b41d403443b..ade25396782 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -157,7 +157,7 @@ const resumeCommand: SlashCommand = { description: 'Resume a conversation from a checkpoint. Usage: /chat resume ', 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 ', kind: CommandKind.BUILT_IN, - autoExecute: false, + autoExecute: true, action: async (context, args): Promise => { const tag = args.trim(); if (!tag) { diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index d762b495d3f..1895143dcc1 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -481,7 +481,7 @@ const updateExtensionsCommand: SlashCommand = { name: 'update', description: 'Update extensions. Usage: update |--all', kind: CommandKind.BUILT_IN, - autoExecute: false, + autoExecute: true, action: updateAction, completion: completeExtensions, }; @@ -516,7 +516,7 @@ const restartCommand: SlashCommand = { name: 'restart', description: 'Restart all extensions', kind: CommandKind.BUILT_IN, - autoExecute: false, + autoExecute: true, action: restartAction, completion: completeExtensions, }; diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 03312c361c0..cc2cfb4fccc 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -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, }; diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 56d37b5e142..254a2b1cb52 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -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, diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index cf77b104564..ee219637b02 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -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, }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d9811b6a7e9..19f86253cc5 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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 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(, { + 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 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(, { + 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(, { + 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, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 3f49901b847..5067a4cc256 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -620,16 +620,41 @@ export const InputPrompt: React.FC = ({ 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; + } + } } } diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 78db047fb08..beabca860bf 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -51,6 +51,8 @@ export interface UseCommandCompletionReturn { getCommandFromSuggestion: ( suggestion: Suggestion, ) => SlashCommand | undefined; + isArgumentCompletion: boolean; + leafCommand: SlashCommand | null; }; getCompletedText: (suggestion: Suggestion) => string | null; } diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 816d24675b7..b4dc46a32a1 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -418,6 +418,8 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { getCommandFromSuggestion: ( suggestion: Suggestion, ) => SlashCommand | undefined; + isArgumentCompletion: boolean; + leafCommand: SlashCommand | null; } { const { enabled, @@ -567,5 +569,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): { completionEnd, getCommandFromSuggestion: (suggestion: Suggestion) => getCommandFromSuggestion(suggestion, parserResult), + isArgumentCompletion: parserResult.isArgumentCompletion, + leafCommand: parserResult.leafCommand, }; } diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 41519e0725f..d16e1084234 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -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; From e16817b2d9c9d238855f1e36f8e45c519fa77a21 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 5 Dec 2025 11:51:19 -0500 Subject: [PATCH 2/2] chore: autoExecute false extension commands --- packages/cli/src/ui/commands/extensionsCommand.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 1895143dcc1..d762b495d3f 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -481,7 +481,7 @@ const updateExtensionsCommand: SlashCommand = { name: 'update', description: 'Update extensions. Usage: update |--all', kind: CommandKind.BUILT_IN, - autoExecute: true, + autoExecute: false, action: updateAction, completion: completeExtensions, }; @@ -516,7 +516,7 @@ const restartCommand: SlashCommand = { name: 'restart', description: 'Restart all extensions', kind: CommandKind.BUILT_IN, - autoExecute: true, + autoExecute: false, action: restartAction, completion: completeExtensions, };