fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling (#19451)

This commit is contained in:
N. Taylor Mullen
2026-02-18 14:08:38 -08:00
committed by GitHub
parent 87f5dd15d6
commit 8910b2720f
4 changed files with 103 additions and 4 deletions

View File

@@ -61,7 +61,7 @@ available combinations.
| Show the next entry in history. | `Ctrl + N (no Shift)` |
| Start reverse search through history. | `Ctrl + R` |
| Submit the selected reverse-search match. | `Enter (no Ctrl)` |
| Accept a suggestion while reverse searching. | `Tab` |
| Accept a suggestion while reverse searching. | `Tab (no Shift)` |
| Browse and rewind previous interactions. | `Double Esc` |
#### Navigation
@@ -79,7 +79,7 @@ available combinations.
| Action | Keys |
| --------------------------------------- | -------------------------------------------------- |
| Accept the inline suggestion. | `Tab`<br />`Enter (no Ctrl)` |
| Accept the inline suggestion. | `Tab (no Shift)`<br />`Enter (no Ctrl)` |
| Move to the previous completion option. | `Up Arrow (no Shift)`<br />`Ctrl + P (no Shift)` |
| Move to the next completion option. | `Down Arrow (no Shift)`<br />`Ctrl + N (no Shift)` |
| Expand an inline suggestion. | `Right Arrow` |

View File

@@ -212,7 +212,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
[Command.REWIND]: [{ key: 'double escape' }],
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab', shift: false }],
// Navigation
[Command.NAVIGATION_UP]: [{ key: 'up', shift: false }],
@@ -231,7 +231,10 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION]: [
{ key: 'tab', shift: false },
{ key: 'return', ctrl: false },
],
[Command.COMPLETION_UP]: [
{ key: 'up', shift: false },
{ key: 'p', shift: false, ctrl: true },

View File

@@ -1221,6 +1221,36 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT autocomplete on Shift+Tab', async () => {
const suggestion = { label: 'about', value: 'about' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
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('\x1b[Z'); // Shift+Tab
});
// We need to wait a bit to ensure handleAutocomplete was NOT called
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('should autocomplete custom commands from .toml files on Enter', async () => {
const customCommand: SlashCommand = {
name: 'find-capital',
@@ -2659,6 +2689,38 @@ describe('InputPrompt', () => {
unmount();
}, 15000);
it('should NOT autocomplete on Shift+Tab in reverse search', async () => {
const mockHandleAutocomplete = vi.fn();
mockedUseReverseSearchCompletion.mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: 'echo hello', value: 'echo hello' }],
showSuggestions: true,
activeSuggestionIndex: 0,
handleAutocomplete: mockHandleAutocomplete,
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\x12'); // Ctrl+R
});
await act(async () => {
stdin.write('\x1b[Z'); // Shift+Tab
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockHandleAutocomplete).not.toHaveBeenCalled();
unmount();
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
// Mock the reverse search completion to return suggestions
mockedUseReverseSearchCompletion.mockReturnValue({
@@ -3035,6 +3097,39 @@ describe('InputPrompt', () => {
},
);
it('should NOT accept ghost text on Shift+Tab', async () => {
const mockAccept = vi.fn();
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
promptCompletion: {
text: 'ghost text',
accept: mockAccept,
clear: vi.fn(),
isLoading: false,
isActive: true,
markSelected: vi.fn(),
},
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\x1b[Z'); // Shift+Tab
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(mockAccept).not.toHaveBeenCalled();
unmount();
});
it('should not reveal clean UI details on Shift+Tab when hidden', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,

View File

@@ -975,6 +975,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!key.shift &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {