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

@@ -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' }],
};

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();
}

View File

@@ -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>
)}

View 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();
});
});

View File

@@ -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);

View File

@@ -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>
);
})}

View File

@@ -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 │

View 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..."
`;

View File

@@ -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(

View File

@@ -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