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
2 changes: 2 additions & 0 deletions packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
vimEnabled={vimEnabled}
vimMode={vimMode}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
unmount();
});

it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(true);

const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="NORMAL"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);

await act(async () => {
stdin.write('?');
});

expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
expect(vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();

unmount();
});

it('should open shortcuts help with ? in vim INSERT mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(false);

const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="INSERT"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);

await act(async () => {
stdin.write('?');
});

await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
});

unmount();
});

it.each([
{
name: 'terminal paste event occurs',
Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import type { VimMode } from '../contexts/VimModeContext.js';

const SCROLLBAR_GUTTER_WIDTH = 1;

Expand Down Expand Up @@ -126,6 +127,8 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
vimEnabled?: boolean;
vimMode?: VimMode;
isEmbeddedShellFocused?: boolean;
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
Expand Down Expand Up @@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onEscapePromptChange,
onSuggestionsVisibilityChange,
vimHandleInput,
vimEnabled,
vimMode,
isEmbeddedShellFocused,
setQueueErrorMessage,
streamingState,
Expand Down Expand Up @@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}

if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
if (
key.sequence === '?' &&
key.insertable &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
Expand All @@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
buffer.text.length === 0 &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(true);
return true;
Expand Down Expand Up @@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
resetEscapeState,
vimHandleInput,
vimEnabled,
vimMode,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/ui/hooks/vim.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
});
});

describe('should handle unmapped keys in Normal mode', () => {
type UnmappedKeyCase = {
char: string;
insertable: boolean;
};
it.each<UnmappedKeyCase>([
{ char: 'm', insertable: true },
{ char: 'n', insertable: true },
{ char: 'p', insertable: true },
{ char: 'q', insertable: true },
{ char: 's', insertable: true },
{ char: 'v', insertable: true },
{ char: 'y', insertable: true },
{ char: 'z', insertable: true },
{ char: 'H', insertable: true },
{ char: 'J', insertable: true },
{ char: 'K', insertable: true },
{ char: 'L', insertable: true },
{ char: 'M', insertable: true },
{ char: 'N', insertable: true },
{ char: 'P', insertable: true },
{ char: 'Q', insertable: true },
{ char: 'R', insertable: true },
{ char: 'S', insertable: true },
{ char: 'U', insertable: true },
{ char: 'V', insertable: true },
{ char: 'Y', insertable: true },
{ char: 'Z', insertable: true },
{ char: '/', insertable: true },
{ char: '#', insertable: true },
{ char: '%', insertable: true },
{ char: '&', insertable: true },
{ char: "'", insertable: true },
{ char: '(', insertable: true },
{ char: ')', insertable: true },
{ char: '*', insertable: true },
{ char: '+', insertable: true },
{ char: '-', insertable: true },
{ char: '/', insertable: true },
{ char: ':', insertable: true },
{ char: '<', insertable: true },
{ char: '=', insertable: true },
{ char: '>', insertable: true },
{ char: '@', insertable: true },
{ char: '[', insertable: true },
{ char: '\\', insertable: true },
{ char: ']', insertable: true },
{ char: '_', insertable: true },
{ char: '`', insertable: true },
{ char: '{', insertable: true },
{ char: '|', insertable: true },
{ char: '}', insertable: true },
])(
'$char: should be swallowed and do nothing in Normal mode',
async ({ char, insertable }) => {
const { result } = await renderVimHook();
exitInsertMode(result);

let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ sequence: char, name: char, insertable }),
);
});

expect(handled).toBe(true);
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');

expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
},
);
});

describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
const { result } = await renderVimHook();
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/ui/hooks/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });

// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
return true;
}

// Not handled by vim so allow other handlers to process it.
return false;
}
Expand Down
Loading