mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
feat (cli): Add command search using Ctrl+r (#5539)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -57,6 +57,10 @@ export enum Command {
|
||||
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
|
||||
TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus',
|
||||
|
||||
// Suggestion expansion
|
||||
EXPAND_SUGGESTION = 'expandSuggestion',
|
||||
COLLAPSE_SUGGESTION = 'collapseSuggestion',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,4 +168,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
[Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }],
|
||||
|
||||
// Suggestion expansion
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from './shared/text-buffer.js';
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from '../utils/clipboardUtils.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
@@ -89,12 +88,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}, [dirs.length, dirsChanged]);
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
|
||||
0, 0,
|
||||
]);
|
||||
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
|
||||
const historyData = shellHistory.history;
|
||||
const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
|
||||
useState<number>(-1);
|
||||
const shellHistory = useShellHistory(config.getProjectRoot());
|
||||
const shellHistoryData = shellHistory.history;
|
||||
|
||||
const completion = useCommandCompletion(
|
||||
buffer,
|
||||
@@ -108,12 +110,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const reverseSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
historyData,
|
||||
shellHistoryData,
|
||||
reverseSearchActive,
|
||||
);
|
||||
|
||||
const commandSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
userMessages,
|
||||
commandSearchActive,
|
||||
);
|
||||
|
||||
const resetCompletionState = completion.resetCompletionState;
|
||||
const resetReverseSearchCompletionState =
|
||||
reverseSearchCompletion.resetCompletionState;
|
||||
const resetCommandSearchCompletionState =
|
||||
commandSearchCompletion.resetCompletionState;
|
||||
|
||||
const resetEscapeState = useCallback(() => {
|
||||
if (escapeTimerRef.current) {
|
||||
@@ -189,6 +200,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (justNavigatedHistory) {
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
resetCommandSearchCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
setJustNavigatedHistory(false);
|
||||
}
|
||||
}, [
|
||||
@@ -197,6 +210,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetCompletionState,
|
||||
setJustNavigatedHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
resetCommandSearchCompletionState,
|
||||
]);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
@@ -296,9 +310,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (reverseSearchActive) {
|
||||
setReverseSearchActive(false);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
resetCompletion: () => void,
|
||||
) => {
|
||||
setActive(false);
|
||||
resetCompletion();
|
||||
buffer.setText(textBeforeReverseSearch);
|
||||
const offset = logicalPosToOffset(
|
||||
buffer.lines,
|
||||
@@ -306,8 +323,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
cursorPosition[1],
|
||||
);
|
||||
buffer.moveToOffset(offset);
|
||||
setExpandedSuggestionIndex(-1);
|
||||
};
|
||||
|
||||
if (reverseSearchActive) {
|
||||
cancelSearch(
|
||||
setReverseSearchActive,
|
||||
reverseSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (commandSearchActive) {
|
||||
cancelSearch(
|
||||
setCommandSearchActive,
|
||||
commandSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
@@ -316,6 +349,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
resetEscapeState();
|
||||
return;
|
||||
}
|
||||
@@ -354,14 +388,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (reverseSearchActive) {
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
const isCommandSearch = commandSearchActive;
|
||||
|
||||
const sc = isCommandSearch
|
||||
? commandSearchCompletion
|
||||
: reverseSearchCompletion;
|
||||
|
||||
const {
|
||||
activeSuggestionIndex,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
showSuggestions,
|
||||
suggestions,
|
||||
} = reverseSearchCompletion;
|
||||
} = sc;
|
||||
const setActive = isCommandSearch
|
||||
? setCommandSearchActive
|
||||
: setReverseSearchActive;
|
||||
const resetState = sc.resetCompletionState;
|
||||
|
||||
if (showSuggestions) {
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
@@ -372,10 +416,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
navigateDown();
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
setReverseSearchActive(false);
|
||||
sc.handleAutocomplete(activeSuggestionIndex);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -386,8 +442,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? suggestions[activeSuggestionIndex].value
|
||||
: buffer.text;
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
reverseSearchCompletion.resetCompletionState();
|
||||
setReverseSearchActive(false);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,10 +466,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -426,6 +484,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
completion.handleAutocomplete(targetIndex);
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -443,6 +502,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
@@ -566,6 +632,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
!key.meta
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -589,6 +656,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -713,6 +782,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
]);
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
const getActiveCompletion = () => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
if (reverseSearchActive) return reverseSearchCompletion;
|
||||
return completion;
|
||||
};
|
||||
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
const showAutoAcceptStyling =
|
||||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||||
@@ -756,6 +833,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) : (
|
||||
'!'
|
||||
)
|
||||
) : commandSearchActive ? (
|
||||
<Text color={theme.text.accent}>(r:) </Text>
|
||||
) : showYoloStyling ? (
|
||||
'*'
|
||||
) : (
|
||||
@@ -886,27 +965,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{completion.showSuggestions && (
|
||||
{shouldShowSuggestions && (
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={completion.suggestions}
|
||||
activeIndex={completion.activeSuggestionIndex}
|
||||
isLoading={completion.isLoadingSuggestions}
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
isLoading={activeCompletion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={completion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{reverseSearchActive && (
|
||||
<Box paddingRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={reverseSearchCompletion.suggestions}
|
||||
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
|
||||
isLoading={reverseSearchCompletion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={reverseSearchCompletion.visibleStartIndex}
|
||||
scrollOffset={activeCompletion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
mode={
|
||||
buffer.text.startsWith('/') &&
|
||||
!reverseSearchActive &&
|
||||
!commandSearchActive
|
||||
? 'slash'
|
||||
: 'reverse'
|
||||
}
|
||||
expandedIndex={expandedSuggestionIndex}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
123
packages/cli/src/ui/components/PrepareLabel.test.tsx
Normal file
123
packages/cli/src/ui/components/PrepareLabel.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
|
||||
describe('PrepareLabel', () => {
|
||||
const color = 'white';
|
||||
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
|
||||
|
||||
it('renders plain label when no match (short label)', () => {
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label="simple command"
|
||||
userInput=""
|
||||
matchedIndex={undefined}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long label when collapsed and no match', () => {
|
||||
const long = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(f.length).toBe(MAX_WIDTH + 3);
|
||||
expect(out).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows full long label when expanded and no match', () => {
|
||||
const long = 'y'.repeat(MAX_WIDTH + 25);
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label={long}
|
||||
userInput=""
|
||||
textColor={color}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.length).toBe(long.length);
|
||||
expect(out).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('highlights matched substring when expanded (text only visible)', () => {
|
||||
const label = 'run: git commit -m "feat: add search"';
|
||||
const userInput = 'commit';
|
||||
const matchedIndex = label.indexOf(userInput);
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label={label}
|
||||
userInput={userInput}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('creates centered window around match when collapsed', () => {
|
||||
const prefix = 'cd /very/long/path/that/keeps/going/'.repeat(3);
|
||||
const core = 'search-here';
|
||||
const suffix = '/and/then/some/more/components/'.repeat(3);
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes(core)).toBe(true);
|
||||
expect(f.startsWith('...')).toBe(true);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(out).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates match itself when match is very long', () => {
|
||||
const prefix = 'find ';
|
||||
const core = 'x'.repeat(MAX_WIDTH + 25);
|
||||
const suffix = ' in this text';
|
||||
const label = prefix + core + suffix;
|
||||
const matchedIndex = prefix.length;
|
||||
const { lastFrame } = render(
|
||||
<PrepareLabel
|
||||
label={label}
|
||||
userInput={core}
|
||||
matchedIndex={matchedIndex}
|
||||
textColor={color}
|
||||
isExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const out = lastFrame();
|
||||
const f = flat(out);
|
||||
expect(f.includes('...')).toBe(true);
|
||||
expect(f.startsWith('...')).toBe(false);
|
||||
expect(f.endsWith('...')).toBe(true);
|
||||
expect(f.length).toBe(MAX_WIDTH + 2);
|
||||
expect(out).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -4,45 +4,113 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
interface PrepareLabelProps {
|
||||
export const MAX_WIDTH = 150; // Maximum width for the text that is shown
|
||||
|
||||
export interface PrepareLabelProps {
|
||||
label: string;
|
||||
matchedIndex?: number;
|
||||
userInput: string;
|
||||
textColor: string;
|
||||
highlightColor?: string;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
const _PrepareLabel: React.FC<PrepareLabelProps> = ({
|
||||
label,
|
||||
matchedIndex,
|
||||
userInput,
|
||||
textColor,
|
||||
highlightColor = theme.status.warning,
|
||||
isExpanded = false,
|
||||
}) => {
|
||||
if (
|
||||
matchedIndex === undefined ||
|
||||
matchedIndex < 0 ||
|
||||
matchedIndex >= label.length ||
|
||||
userInput.length === 0
|
||||
) {
|
||||
return <Text color={textColor}>{label}</Text>;
|
||||
const hasMatch =
|
||||
matchedIndex !== undefined &&
|
||||
matchedIndex >= 0 &&
|
||||
matchedIndex < label.length &&
|
||||
userInput.length > 0;
|
||||
|
||||
// Render the plain label if there's no match
|
||||
if (!hasMatch) {
|
||||
const display = isExpanded
|
||||
? label
|
||||
: label.length > MAX_WIDTH
|
||||
? label.slice(0, MAX_WIDTH) + '...'
|
||||
: label;
|
||||
return (
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
{display}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const start = label.slice(0, matchedIndex);
|
||||
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
|
||||
const end = label.slice(matchedIndex + userInput.length);
|
||||
const matchLength = userInput.length;
|
||||
let before = '';
|
||||
let match = '';
|
||||
let after = '';
|
||||
|
||||
// Case 1: Show the full string if it's expanded or already fits
|
||||
if (isExpanded || label.length <= MAX_WIDTH) {
|
||||
before = label.slice(0, matchedIndex);
|
||||
match = label.slice(matchedIndex, matchedIndex + matchLength);
|
||||
after = label.slice(matchedIndex + matchLength);
|
||||
}
|
||||
// Case 2: The match itself is too long, so we only show a truncated portion of the match
|
||||
else if (matchLength >= MAX_WIDTH) {
|
||||
match = label.slice(matchedIndex, matchedIndex + MAX_WIDTH - 1) + '...';
|
||||
}
|
||||
// Case 3: Truncate the string to create a window around the match
|
||||
else {
|
||||
const contextSpace = MAX_WIDTH - matchLength;
|
||||
const beforeSpace = Math.floor(contextSpace / 2);
|
||||
const afterSpace = Math.ceil(contextSpace / 2);
|
||||
|
||||
let start = matchedIndex - beforeSpace;
|
||||
let end = matchedIndex + matchLength + afterSpace;
|
||||
|
||||
if (start < 0) {
|
||||
end += -start; // Slide window right
|
||||
start = 0;
|
||||
}
|
||||
if (end > label.length) {
|
||||
start -= end - label.length; // Slide window left
|
||||
end = label.length;
|
||||
}
|
||||
start = Math.max(0, start);
|
||||
|
||||
const finalMatchIndex = matchedIndex - start;
|
||||
const slicedLabel = label.slice(start, end);
|
||||
|
||||
before = slicedLabel.slice(0, finalMatchIndex);
|
||||
match = slicedLabel.slice(finalMatchIndex, finalMatchIndex + matchLength);
|
||||
after = slicedLabel.slice(finalMatchIndex + matchLength);
|
||||
|
||||
if (start > 0) {
|
||||
before = before.length >= 3 ? '...' + before.slice(3) : '...';
|
||||
}
|
||||
if (end < label.length) {
|
||||
after = after.length >= 3 ? after.slice(0, -3) + '...' : '...';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Text>
|
||||
<Text color={textColor}>{start}</Text>
|
||||
<Text color="black" bold backgroundColor={highlightColor}>
|
||||
{match}
|
||||
</Text>
|
||||
<Text color={textColor}>{end}</Text>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{before}
|
||||
{match
|
||||
? match.split(/(\s+)/).map((part, index) => (
|
||||
<Text
|
||||
key={`match-${index}`}
|
||||
color={theme.background.primary}
|
||||
backgroundColor={theme.text.primary}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const PrepareLabel = React.memo(_PrepareLabel);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { PrepareLabel } from './PrepareLabel.js';
|
||||
import { PrepareLabel, MAX_WIDTH } from './PrepareLabel.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import { Colors } from '../colors.js';
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -22,9 +23,12 @@ interface SuggestionsDisplayProps {
|
||||
width: number;
|
||||
scrollOffset: number;
|
||||
userInput: string;
|
||||
mode: 'reverse' | 'slash';
|
||||
expandedIndex?: number;
|
||||
}
|
||||
|
||||
export const MAX_SUGGESTIONS_TO_SHOW = 8;
|
||||
export { MAX_WIDTH };
|
||||
|
||||
export function SuggestionsDisplay({
|
||||
suggestions,
|
||||
@@ -33,6 +37,8 @@ export function SuggestionsDisplay({
|
||||
width,
|
||||
scrollOffset,
|
||||
userInput,
|
||||
mode,
|
||||
expandedIndex,
|
||||
}: SuggestionsDisplayProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -60,7 +66,8 @@ export function SuggestionsDisplay({
|
||||
const maxLabelLength = Math.max(
|
||||
...suggestions.map((s) => getFullLabel(s).length),
|
||||
);
|
||||
const commandColumnWidth = Math.min(maxLabelLength, Math.floor(width * 0.5));
|
||||
const commandColumnWidth =
|
||||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
@@ -69,19 +76,26 @@ export function SuggestionsDisplay({
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
const originalIndex = startIndex + index;
|
||||
const isActive = originalIndex === activeIndex;
|
||||
const isExpanded = originalIndex === expandedIndex;
|
||||
const textColor = isActive ? theme.text.accent : theme.text.secondary;
|
||||
const isLong = suggestion.value.length >= MAX_WIDTH;
|
||||
const labelElement = (
|
||||
<PrepareLabel
|
||||
label={suggestion.label}
|
||||
label={suggestion.value}
|
||||
matchedIndex={suggestion.matchedIndex}
|
||||
userInput={userInput}
|
||||
textColor={textColor}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={`${suggestion.value}-${originalIndex}`} flexDirection="row">
|
||||
<Box width={commandColumnWidth} flexShrink={0}>
|
||||
<Box
|
||||
{...(mode === 'slash'
|
||||
? { width: commandColumnWidth, flexShrink: 0 as const }
|
||||
: { flexShrink: 1 as const })}
|
||||
>
|
||||
<Box>
|
||||
{labelElement}
|
||||
{suggestion.commandKind === CommandKind.MCP_PROMPT && (
|
||||
@@ -97,6 +111,11 @@ export function SuggestionsDisplay({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isActive && isLong && (
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>{isExpanded ? ' ← ' : ' → '}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
|
||||
..."
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) Type your message or @path/to/file │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
|
||||
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
|
||||
llllllllllllllllllllllllllllllllllllllllllllllllll"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ (r:) commit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ! Type your message or @path/to/file │
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`PrepareLabel > creates centered window around match when collapsed 1`] = `
|
||||
"...ry/long/path/that/keeps/going/cd /very/long/path/that/keeps/going/search-here/and/then/some/more/
|
||||
components//and/then/some/more/components//and/..."
|
||||
`;
|
||||
|
||||
exports[`PrepareLabel > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
|
||||
|
||||
exports[`PrepareLabel > renders plain label when no match (short label) 1`] = `"simple command"`;
|
||||
|
||||
exports[`PrepareLabel > shows full long label when expanded and no match 1`] = `
|
||||
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
|
||||
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
|
||||
`;
|
||||
|
||||
exports[`PrepareLabel > truncates long label when collapsed and no match 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
|
||||
exports[`PrepareLabel > truncates match itself when match is very long 1`] = `
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
|
||||
`;
|
||||
@@ -4,11 +4,20 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { Suggestion } from '../components/SuggestionsDisplay.js';
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay = 200): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(handle);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export interface UseReverseSearchCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
@@ -23,7 +32,7 @@ export interface UseReverseSearchCompletionReturn {
|
||||
|
||||
export function useReverseSearchCompletion(
|
||||
buffer: TextBuffer,
|
||||
shellHistory: readonly string[],
|
||||
history: readonly string[],
|
||||
reverseSearchActive: boolean,
|
||||
): UseReverseSearchCompletionReturn {
|
||||
const {
|
||||
@@ -32,45 +41,95 @@ export function useReverseSearchCompletion(
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
setVisibleStartIndex,
|
||||
} = useCompletion();
|
||||
|
||||
const debouncedQuery = useDebouncedValue(buffer.text, 100);
|
||||
|
||||
// incremental search
|
||||
const prevQueryRef = useRef<string>('');
|
||||
const prevMatchesRef = useRef<Suggestion[]>([]);
|
||||
|
||||
// Clear incremental cache when activating reverse search
|
||||
useEffect(() => {
|
||||
if (reverseSearchActive) {
|
||||
prevQueryRef.current = '';
|
||||
prevMatchesRef.current = [];
|
||||
}
|
||||
}, [reverseSearchActive]);
|
||||
|
||||
// Also clear cache when history changes so new items are considered
|
||||
useEffect(() => {
|
||||
prevQueryRef.current = '';
|
||||
prevMatchesRef.current = [];
|
||||
}, [history]);
|
||||
|
||||
const searchHistory = useCallback(
|
||||
(query: string, items: readonly string[]) => {
|
||||
const out: Suggestion[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const cmd = items[i];
|
||||
const idx = cmd.toLowerCase().indexOf(query);
|
||||
if (idx !== -1) {
|
||||
out.push({ label: cmd, value: cmd, matchedIndex: idx });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const matches = useMemo<Suggestion[]>(() => {
|
||||
if (!reverseSearchActive) return [];
|
||||
if (debouncedQuery.length === 0)
|
||||
return history.map((cmd) => ({
|
||||
label: cmd,
|
||||
value: cmd,
|
||||
matchedIndex: -1,
|
||||
}));
|
||||
|
||||
const query = debouncedQuery.toLowerCase();
|
||||
const canUseCache =
|
||||
prevQueryRef.current &&
|
||||
query.startsWith(prevQueryRef.current) &&
|
||||
prevMatchesRef.current.length > 0;
|
||||
|
||||
const source = canUseCache
|
||||
? prevMatchesRef.current.map((m) => m.value)
|
||||
: history;
|
||||
|
||||
return searchHistory(query, source);
|
||||
}, [debouncedQuery, history, reverseSearchActive, searchHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reverseSearchActive) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}, [reverseSearchActive, resetCompletionState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reverseSearchActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const q = buffer.text.toLowerCase();
|
||||
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
|
||||
const idx = cmd.toLowerCase().indexOf(q);
|
||||
if (idx !== -1) {
|
||||
acc.push({ label: cmd, value: cmd, matchedIndex: idx });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
setSuggestions(matches);
|
||||
setShowSuggestions(matches.length > 0);
|
||||
setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
|
||||
const hasAny = matches.length > 0;
|
||||
setShowSuggestions(hasAny);
|
||||
setActiveSuggestionIndex(hasAny ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
|
||||
prevQueryRef.current = debouncedQuery.toLowerCase();
|
||||
prevMatchesRef.current = matches;
|
||||
}, [
|
||||
buffer.text,
|
||||
shellHistory,
|
||||
debouncedQuery,
|
||||
matches,
|
||||
reverseSearchActive,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
setSuggestions,
|
||||
setShowSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setVisibleStartIndex,
|
||||
resetCompletionState,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('keyMatchers', () => {
|
||||
key.name === 'tab',
|
||||
[Command.TOGGLE_SHELL_INPUT_FOCUS]: (key: Key) =>
|
||||
key.ctrl && key.name === 'f',
|
||||
[Command.EXPAND_SUGGESTION]: (key: Key) => key.name === 'right',
|
||||
[Command.COLLAPSE_SUGGESTION]: (key: Key) => key.name === 'left',
|
||||
};
|
||||
|
||||
// Test data for each command with positive and negative test cases
|
||||
|
||||
Reference in New Issue
Block a user