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