Compare commits

...

5 Commits

Author SHA1 Message Date
Adib234 f6e7f01566 Merge branch 'main' into adibakm/ask-user-file 2026-05-06 09:38:34 -04:00
Adib234 3c240a8db3 Merge branch 'main' into adibakm/ask-user-file 2026-05-05 14:23:26 -04:00
A.K.M. Adib 45f2bc3822 refactor(cli): make CommandContext optional in completion hooks and improve SuggestionsDisplay UX 2026-05-05 08:49:40 -04:00
A.K.M. Adib 327ba49b3d restore 2026-05-04 16:49:36 -04:00
A.K.M. Adib 4b1ce5b1b2 feat(cli): support @-mentioning files in AskUser custom input 2026-05-04 16:20:07 -04:00
8 changed files with 159 additions and 23 deletions
@@ -22,7 +22,7 @@ import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { TextInput } from './shared/TextInput.js';
import { AutocompleteTextInput } from './shared/AutocompleteTextInput.js';
import { formatCommand } from '../key/keybindingUtils.js';
import {
useTextBuffer,
@@ -396,10 +396,12 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.status.success}>{'> '}</Text>
<TextInput
<AutocompleteTextInput
buffer={buffer}
placeholder={placeholder}
onSubmit={handleSubmit}
availableWidth={availableWidth}
suggestionsPosition="below"
/>
</Box>
@@ -948,10 +950,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
/>
)}
<Text color={theme.text.primary}> </Text>
<TextInput
<AutocompleteTextInput
buffer={customBuffer}
placeholder={placeholder}
focus={context.isSelected}
availableWidth={availableWidth}
suggestionsPosition="below"
onSubmit={(val) => {
if (question.multiSelect) {
const fullAnswer = buildAnswerString(
@@ -43,10 +43,7 @@ import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import {
useCommandCompletion,
CompletionMode,
} from '../hooks/useCommandCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -1759,14 +1756,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
scrollOffset={activeCompletion.visibleStartIndex}
userInput={buffer.text}
mode={
completion.completionMode === CompletionMode.AT ||
completion.completionMode === CompletionMode.SHELL
suggestionsPosition === 'above'
? 'reverse'
: buffer.text.startsWith('/') &&
!reverseSearchActive &&
!commandSearchActive
? 'slash'
: 'reverse'
: 'normal'
}
expandedIndex={expandedSuggestionIndex}
/>
@@ -25,7 +25,7 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
mode="normal"
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -40,7 +40,7 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
mode="normal"
/>,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
@@ -55,7 +55,7 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
mode="normal"
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -72,7 +72,7 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
mode="normal"
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -93,6 +93,21 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={5}
userInput=""
mode="normal"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders reverse mode correctly', async () => {
const { lastFrame } = await render(
<SuggestionsDisplay
suggestions={mockSuggestions}
activeIndex={0}
isLoading={false}
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
/>,
);
@@ -116,7 +131,7 @@ describe('SuggestionsDisplay', () => {
width={80}
scrollOffset={0}
userInput=""
mode="reverse"
mode="normal"
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -28,7 +28,7 @@ interface SuggestionsDisplayProps {
width: number;
scrollOffset: number;
userInput: string;
mode: 'reverse' | 'slash';
mode?: 'reverse' | 'slash' | 'normal';
expandedIndex?: number;
}
@@ -42,7 +42,7 @@ export function SuggestionsDisplay({
width,
scrollOffset,
userInput,
mode,
mode = 'normal',
expandedIndex,
}: SuggestionsDisplayProps) {
if (isLoading) {
@@ -80,8 +80,17 @@ export function SuggestionsDisplay({
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
return (
<Box flexDirection="column" paddingX={1} width={width}>
{scrollOffset > 0 && <Text color={theme.text.primary}></Text>}
<Box
flexDirection={mode === 'reverse' ? 'column-reverse' : 'column'}
paddingX={1}
width={width}
>
{scrollOffset > 0 && mode !== 'reverse' && (
<Text color={theme.text.primary}></Text>
)}
{endIndex < suggestions.length && mode === 'reverse' && (
<Text color="gray"></Text>
)}
{visibleSuggestions.map((suggestion, index) => {
const originalIndex = startIndex + index;
@@ -153,7 +162,12 @@ export function SuggestionsDisplay({
</Box>
);
})}
{endIndex < suggestions.length && <Text color="gray"></Text>}
{endIndex < suggestions.length && mode !== 'reverse' && (
<Text color="gray"></Text>
)}
{scrollOffset > 0 && mode === 'reverse' && (
<Text color={theme.text.primary}></Text>
)}
{suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
<Text color="gray">
({activeIndex + 1}/{suggestions.length})
@@ -32,6 +32,13 @@ exports[`SuggestionsDisplay > renders loading state 1`] = `
"
`;
exports[`SuggestionsDisplay > renders reverse mode correctly 1`] = `
" command3 Description 3
command2 Description 2
command1 Description 1
"
`;
exports[`SuggestionsDisplay > renders suggestions list 1`] = `
" command1 Description 1
command2 Description 2
@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { TextInput, type TextInputProps } from './TextInput.js';
import { useCommandCompletion } from '../../hooks/useCommandCompletion.js';
import { SuggestionsDisplay } from '../SuggestionsDisplay.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { Command } from '../../key/keyMatchers.js';
export interface AutocompleteTextInputProps extends TextInputProps {
suggestionsPosition?: 'above' | 'below';
availableWidth?: number;
}
/**
* A wrapper around TextInput that provides @-mention autocomplete for files.
*/
export function AutocompleteTextInput(
props: AutocompleteTextInputProps,
): React.JSX.Element {
const {
suggestionsPosition = 'above',
availableWidth = 80,
...textInputProps
} = props;
const config = useConfig();
const keyMatchers = useKeyMatchers();
const completion = useCommandCompletion({
buffer: props.buffer,
cwd: process.cwd(),
slashCommands: [],
shellModeActive: false,
config,
active: props.focus ?? true,
});
const handleKeypress = (key: Key) => {
if (!completion.showSuggestions) return false;
if (key.name === 'tab') {
completion.handleAutocomplete(completion.activeSuggestionIndex);
return true;
}
if (keyMatchers[Command.MOVE_UP](key)) {
completion.navigateUp();
return true;
}
if (keyMatchers[Command.MOVE_DOWN](key)) {
completion.navigateDown();
return true;
}
if (keyMatchers[Command.SUBMIT](key) && !completion.isPerfectMatch) {
completion.handleAutocomplete(completion.activeSuggestionIndex);
return true;
}
return false;
};
useKeypress(handleKeypress, {
isActive: props.focus ?? true,
priority: true,
});
const suggestionsNode = completion.showSuggestions ? (
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
isLoading={completion.isLoadingSuggestions}
width={availableWidth}
scrollOffset={completion.visibleStartIndex}
userInput={props.buffer.text}
mode={suggestionsPosition === 'above' ? 'reverse' : undefined}
/>
</Box>
) : null;
return (
<Box flexDirection="column">
{suggestionsPosition === 'above' && suggestionsNode}
<TextInput {...textInputProps} />
{suggestionsPosition === 'below' && suggestionsNode}
</Box>
);
}
@@ -67,7 +67,7 @@ export interface UseCommandCompletionOptions {
buffer: TextBuffer;
cwd: string;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
commandContext?: CommandContext;
reverseSearchActive?: boolean;
shellModeActive: boolean;
config?: Config;
@@ -145,7 +145,7 @@ interface PerfectMatchResult {
function useCommandSuggestions(
query: string | null,
parserResult: CommandParserResult,
commandContext: CommandContext,
commandContext: CommandContext | undefined,
getFzfForCommands: (
commands: readonly SlashCommand[],
) => FzfCommandCacheEntry | null,
@@ -181,6 +181,13 @@ function useCommandSuggestions(
return;
}
if (!commandContext) {
debugLogger.warn(
'CommandContext is required for argument completion',
);
return;
}
const showLoading = leafCommand.showCompletionLoading !== false;
if (showLoading) {
setIsLoading(true);
@@ -473,7 +480,7 @@ export interface UseSlashCompletionProps {
enabled: boolean;
query: string | null;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
commandContext?: CommandContext;
setSuggestions: (suggestions: Suggestion[]) => void;
setIsLoadingSuggestions: (isLoading: boolean) => void;
setIsPerfectMatch: (isMatch: boolean) => void;