feat: auto-execute simple slash commands on Enter (#13985)

This commit is contained in:
Jack Wotherspoon
2025-12-01 12:29:03 -05:00
committed by GitHub
parent 844d3a4dfa
commit f918af82fe
38 changed files with 393 additions and 9 deletions
@@ -191,6 +191,13 @@ describe('InputPrompt', () => {
isActive: false,
markSelected: vi.fn(),
},
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
slashCompletionRange: {
completionStart: -1,
completionEnd: -1,
getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
},
getCompletedText: vi.fn().mockReturnValue(null),
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -778,6 +785,173 @@ describe('InputPrompt', () => {
unmount();
});
it('should auto-execute commands with autoExecute: true on Enter', async () => {
const aboutCommand: SlashCommand = {
name: 'about',
kind: CommandKind.BUILT_IN,
description: 'About command',
action: vi.fn(),
autoExecute: true,
};
const suggestion = { label: 'about', value: 'about' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(aboutCommand),
getCompletedText: vi.fn().mockReturnValue('/about'),
slashCompletionRange: {
completionStart: 1,
completionEnd: 3, // "/ab" -> start at 1, end at 3
getCommandFromSuggestion: vi.fn(),
},
});
// User typed partial command
props.buffer.setText('/ab');
props.buffer.lines = ['/ab'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should submit the full command constructed from buffer + suggestion
expect(props.onSubmit).toHaveBeenCalledWith('/about');
// Should NOT handle autocomplete (which just fills text)
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete commands with autoExecute: false on Enter', async () => {
const shareCommand: SlashCommand = {
name: 'share',
kind: CommandKind.BUILT_IN,
description: 'Share conversation to file',
action: vi.fn(),
autoExecute: false, // Explicitly set to false
};
const suggestion = { label: 'share', value: 'share' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(shareCommand),
getCompletedText: vi.fn().mockReturnValue('/share'),
});
props.buffer.setText('/sh');
props.buffer.lines = ['/sh'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete to allow adding file argument
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete on Tab, even for executable commands', 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'),
});
props.buffer.setText('/ab');
props.buffer.lines = ['/ab'];
props.buffer.cursor = [0, 3];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\t'); // Tab
});
await waitFor(() => {
// Tab always autocompletes, never executes
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete custom commands from .toml files on Enter', async () => {
const customCommand: SlashCommand = {
name: 'find-capital',
kind: CommandKind.FILE,
description: 'Find capital of a country',
action: vi.fn(),
// No autoExecute flag - custom commands default to undefined
};
const suggestion = { label: 'find-capital', value: 'find-capital' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(customCommand),
getCompletedText: vi.fn().mockReturnValue('/find-capital'),
});
props.buffer.setText('/find');
props.buffer.lines = ['/find'];
props.buffer.cursor = [0, 5];
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
uiActions,
});
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should autocomplete (not execute) since autoExecute is undefined
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
+24 -1
View File
@@ -35,12 +35,15 @@ import {
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import {
isAutoExecutableCommand,
isSlashCommand,
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { StreamingState } from '../types.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -621,7 +624,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.activeSuggestionIndex === -1
? 0 // Default to the first if none is active
: completion.activeSuggestionIndex;
if (targetIndex < completion.suggestions.length) {
const suggestion = completion.suggestions[targetIndex];
const isEnterKey = key.name === 'return' && !key.ctrl;
if (isEnterKey && buffer.text.startsWith('/')) {
const command = completion.getCommandFromSuggestion(suggestion);
if (command && isAutoExecutableCommand(command)) {
const completedText = completion.getCompletedText(suggestion);
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
return;
}
}
}
// Default behavior: auto-complete to prompt box
completion.handleAutocomplete(targetIndex);
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}