Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/ui/commands/chatCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/commands/hooksCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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,
};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/commands/mcpCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/commands/restoreCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
156 changes: 156 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ describe('InputPrompt', () => {
completionStart: -1,
completionEnd: -1,
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
isArgumentCompletion: false,
leafCommand: null,
},
getCompletedText: vi.fn().mockReturnValue(null),
};
Expand Down Expand Up @@ -807,6 +809,8 @@ describe('InputPrompt', () => {
completionStart: 1,
completionEnd: 3, // "/ab" -> start at 1, end at 3
getCommandFromSuggestion: vi.fn(),
isArgumentCompletion: false,
leafCommand: null,
},
});

Expand Down Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,16 +620,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;
}
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/ui/hooks/useCommandCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface UseCommandCompletionReturn {
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
isArgumentCompletion: boolean;
leafCommand: SlashCommand | null;
};
getCompletedText: (suggestion: Suggestion) => string | null;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/ui/hooks/useSlashCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
isArgumentCompletion: boolean;
leafCommand: SlashCommand | null;
} {
const {
enabled,
Expand Down Expand Up @@ -567,5 +569,7 @@ export function useSlashCompletion(props: UseSlashCompletionProps): {
completionEnd,
getCommandFromSuggestion: (suggestion: Suggestion) =>
getCommandFromSuggestion(suggestion, parserResult),
isArgumentCompletion: parserResult.isArgumentCompletion,
leafCommand: parserResult.leafCommand,
};
}
2 changes: 1 addition & 1 deletion packages/cli/src/ui/utils/commandUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down