feat (cli): Add command search using Ctrl+r (#5539)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Ayesha Shafique
2025-09-15 12:49:23 -05:00
committed by GitHub
parent 8c283127d5
commit 0d9c1fba1d
10 changed files with 635 additions and 80 deletions

View File

@@ -25,6 +25,7 @@ import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearch
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
vi.mock('../hooks/useShellHistory.js');
@@ -1789,6 +1790,144 @@ describe('InputPrompt', () => {
});
});
describe('command search (Ctrl+R when not in shell)', () => {
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;
vi.mocked(useReverseSearchCompletion).mockImplementation(
(buffer, data, isActive) => ({
...mockReverseSearchCompletion,
suggestions: isActive
? [
{ label: 'git commit -m "msg"', value: 'git commit -m "msg"' },
{ label: 'git push', value: 'git push' },
]
: [],
showSuggestions: !!isActive,
activeSuggestionIndex: isActive ? 0 : -1,
}),
);
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
act(() => {
stdin.write('\x12'); // Ctrl+R
});
await wait();
const frame = stdout.lastFrame() ?? '';
expect(frame).toContain('(r:)');
expect(frame).toContain('git commit');
expect(frame).toContain('git push');
unmount();
});
it('expands and collapses long suggestion via Right/Left arrows', async () => {
props.shellModeActive = false;
const longValue = 'l'.repeat(200);
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: longValue, value: longValue, matchedIndex: 0 }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
expect(clean(stdout.lastFrame())).toContain('→');
stdin.write('\u001B[C');
await wait();
expect(clean(stdout.lastFrame())).toContain('←');
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-expanded-match',
);
stdin.write('\u001B[D');
await wait();
expect(clean(stdout.lastFrame())).toContain('→');
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-collapsed-match',
);
unmount();
});
it('renders match window and expanded view (snapshots)', async () => {
props.shellModeActive = false;
props.buffer.setText('commit');
const label = 'git commit -m "feat: add search" in src/app';
const matchedIndex = label.indexOf('commit');
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label, value: label, matchedIndex }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-collapsed-match',
);
stdin.write('\u001B[C');
await wait();
expect(stdout.lastFrame()).toMatchSnapshot(
'command-search-expanded-match',
);
unmount();
});
it('does not show expand/collapse indicator for short suggestions', async () => {
props.shellModeActive = false;
const shortValue = 'echo hello';
vi.mocked(useReverseSearchCompletion).mockReturnValue({
...mockReverseSearchCompletion,
suggestions: [{ label: shortValue, value: shortValue }],
showSuggestions: true,
activeSuggestionIndex: 0,
visibleStartIndex: 0,
isLoadingSuggestions: false,
});
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x12');
await wait();
const frame = clean(stdout.lastFrame());
expect(frame).not.toContain('→');
expect(frame).not.toContain('←');
unmount();
});
});
describe('snapshots', () => {
it('should render correctly in shell mode', async () => {
props.shellModeActive = true;
@@ -1821,3 +1960,8 @@ describe('InputPrompt', () => {
});
});
});
function clean(str: string | undefined): string {
if (!str) return '';
// Remove ANSI escape codes and trim whitespace
return stripAnsi(str).trim();
}