diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index ba5ddac587..b261d7aacf 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -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' }],
};
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 99d46d7016..109c6a6f9d 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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();
+}
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index e14dab3fc4..fd01bde032 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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 = ({
}
}, [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(-1);
+ const shellHistory = useShellHistory(config.getProjectRoot());
+ const shellHistoryData = shellHistory.history;
const completion = useCommandCompletion(
buffer,
@@ -108,12 +110,21 @@ export const InputPrompt: React.FC = ({
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 = ({
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
+ resetCommandSearchCompletionState();
+ setExpandedSuggestionIndex(-1);
setJustNavigatedHistory(false);
}
}, [
@@ -197,6 +210,7 @@ export const InputPrompt: React.FC = ({
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
+ resetCommandSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -296,9 +310,12 @@ export const InputPrompt: React.FC = ({
}
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 = ({
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 = ({
if (completion.showSuggestions) {
completion.resetCompletionState();
+ setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
}
@@ -354,14 +388,24 @@ export const InputPrompt: React.FC = ({
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 = ({
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 = ({
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
- reverseSearchCompletion.resetCompletionState();
- setReverseSearchActive(false);
+ resetState();
+ setActive(false);
return;
}
@@ -410,10 +466,12 @@ export const InputPrompt: React.FC = ({
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 = ({
: 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 = ({
}
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 = ({
!key.meta
) {
completion.promptCompletion.clear();
+ setExpandedSuggestionIndex(-1);
}
},
[
@@ -589,6 +656,8 @@ export const InputPrompt: React.FC = ({
textBeforeReverseSearch,
cursorPosition,
recentPasteTime,
+ commandSearchActive,
+ commandSearchCompletion,
],
);
@@ -713,6 +782,14 @@ export const InputPrompt: React.FC = ({
]);
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 = ({
) : (
'!'
)
+ ) : commandSearchActive ? (
+ (r:)
) : showYoloStyling ? (
'*'
) : (
@@ -886,27 +965,23 @@ export const InputPrompt: React.FC = ({
)}
- {completion.showSuggestions && (
+ {shouldShowSuggestions && (
-
- )}
- {reverseSearchActive && (
-
-
)}
diff --git a/packages/cli/src/ui/components/PrepareLabel.test.tsx b/packages/cli/src/ui/components/PrepareLabel.test.tsx
new file mode 100644
index 0000000000..d6e004def8
--- /dev/null
+++ b/packages/cli/src/ui/components/PrepareLabel.test.tsx
@@ -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(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('truncates long label when collapsed and no match', () => {
+ const long = 'x'.repeat(MAX_WIDTH + 25);
+ const { lastFrame } = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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();
+ });
+});
diff --git a/packages/cli/src/ui/components/PrepareLabel.tsx b/packages/cli/src/ui/components/PrepareLabel.tsx
index 37ad5a3313..759e84b100 100644
--- a/packages/cli/src/ui/components/PrepareLabel.tsx
+++ b/packages/cli/src/ui/components/PrepareLabel.tsx
@@ -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 = ({
+const _PrepareLabel: React.FC = ({
label,
matchedIndex,
userInput,
textColor,
- highlightColor = theme.status.warning,
+ isExpanded = false,
}) => {
- if (
- matchedIndex === undefined ||
- matchedIndex < 0 ||
- matchedIndex >= label.length ||
- userInput.length === 0
- ) {
- return {label};
+ 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 (
+
+ {display}
+
+ );
}
- 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 (
-
- {start}
-
- {match}
-
- {end}
+
+ {before}
+ {match
+ ? match.split(/(\s+)/).map((part, index) => (
+
+ {part}
+
+ ))
+ : null}
+ {after}
);
};
+
+export const PrepareLabel = React.memo(_PrepareLabel);
diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
index ffd48713ec..7d7c405471 100644
--- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx
+++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx
@@ -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 (
@@ -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 = (
);
return (
-
+
{labelElement}
{suggestion.commandKind === CommandKind.MCP_PROMPT && (
@@ -97,6 +111,11 @@ export function SuggestionsDisplay({
)}
+ {isActive && isLong && (
+
+ {isExpanded ? ' ← ' : ' → '}
+
+ )}
);
})}
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index b955931d41..de5fc6c90a 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -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 │
diff --git a/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap
new file mode 100644
index 0000000000..a5e7a067c0
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/PrepareLabel.test.tsx.snap
@@ -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..."
+`;
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
index 1e333deb1d..d90875c10c 100644
--- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
@@ -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(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('');
+ const prevMatchesRef = useRef([]);
+
+ // 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(() => {
+ 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((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(
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index eb7f2332b9..690a5cee49 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -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