feat(cli): allow executing commands on perfect match (#4397)
Co-authored-by: Jenna Inouye <jinouye@google.com>
This commit is contained in:
parent
8497176168
commit
de27ea6095
|
@ -9,7 +9,7 @@ import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
|
@ -341,7 +341,7 @@ describe('InputPrompt', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should complete a partial parent command and add a space', async () => {
|
it('should complete a partial parent command', async () => {
|
||||||
// SCENARIO: /mem -> Tab
|
// SCENARIO: /mem -> Tab
|
||||||
mockedUseCompletion.mockReturnValue({
|
mockedUseCompletion.mockReturnValue({
|
||||||
...mockCompletion,
|
...mockCompletion,
|
||||||
|
@ -357,12 +357,12 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
it('should append a sub-command when the parent command is already complete', async () => {
|
||||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||||
mockedUseCompletion.mockReturnValue({
|
mockedUseCompletion.mockReturnValue({
|
||||||
...mockCompletion,
|
...mockCompletion,
|
||||||
showSuggestions: true,
|
showSuggestions: true,
|
||||||
|
@ -380,13 +380,12 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle the "backspace" edge case correctly', async () => {
|
it('should handle the "backspace" edge case correctly', async () => {
|
||||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||||
// This is the critical bug we fixed.
|
|
||||||
mockedUseCompletion.mockReturnValue({
|
mockedUseCompletion.mockReturnValue({
|
||||||
...mockCompletion,
|
...mockCompletion,
|
||||||
showSuggestions: true,
|
showSuggestions: true,
|
||||||
|
@ -405,8 +404,8 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// It should NOT become '/show '. It should correctly become '/memory show '.
|
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -426,7 +425,7 @@ describe('InputPrompt', () => {
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -446,7 +445,7 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
// The app should autocomplete the text, NOT submit.
|
// The app should autocomplete the text, NOT submit.
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
|
||||||
|
|
||||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
|
@ -471,10 +470,10 @@ describe('InputPrompt', () => {
|
||||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
stdin.write('\t'); // Press Tab
|
stdin.write('\t'); // Press Tab for autocomplete
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
expect(props.buffer.setText).toHaveBeenCalledWith('/help');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -505,7 +504,6 @@ describe('InputPrompt', () => {
|
||||||
await wait();
|
await wait();
|
||||||
|
|
||||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -530,7 +528,10 @@ describe('InputPrompt', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add a newline on enter when the line ends with a backslash', async () => {
|
it('should add a newline on enter when the line ends with a backslash', async () => {
|
||||||
props.buffer.setText('first line\\');
|
// This test simulates multi-line input, not submission
|
||||||
|
mockBuffer.text = 'first line\\';
|
||||||
|
mockBuffer.cursor = [0, 11];
|
||||||
|
mockBuffer.lines = ['first line\\'];
|
||||||
|
|
||||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
await wait();
|
await wait();
|
||||||
|
|
|
@ -162,7 +162,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
// - Otherwise, the base is everything EXCEPT the last partial part.
|
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||||
const basePath =
|
const basePath =
|
||||||
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
||||||
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
const newValue = `/${[...basePath, suggestion].join(' ')}`;
|
||||||
|
|
||||||
buffer.setText(newValue);
|
buffer.setText(newValue);
|
||||||
} else {
|
} else {
|
||||||
|
@ -266,6 +266,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the command is a perfect match, pressing enter should execute it.
|
||||||
|
if (completion.isPerfectMatch && key.name === 'return') {
|
||||||
|
handleSubmitAndClear(buffer.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
if (completion.suggestions.length > 1) {
|
if (completion.suggestions.length > 1) {
|
||||||
if (key.name === 'up') {
|
if (key.name === 'up') {
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** @vitest-environment jsdom */
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
|
|
@ -28,6 +28,7 @@ export interface UseCompletionReturn {
|
||||||
visibleStartIndex: number;
|
visibleStartIndex: number;
|
||||||
showSuggestions: boolean;
|
showSuggestions: boolean;
|
||||||
isLoadingSuggestions: boolean;
|
isLoadingSuggestions: boolean;
|
||||||
|
isPerfectMatch: boolean;
|
||||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
resetCompletionState: () => void;
|
resetCompletionState: () => void;
|
||||||
|
@ -50,6 +51,7 @@ export function useCompletion(
|
||||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
|
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
||||||
|
|
||||||
const resetCompletionState = useCallback(() => {
|
const resetCompletionState = useCallback(() => {
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
|
@ -57,6 +59,7 @@ export function useCompletion(
|
||||||
setVisibleStartIndex(0);
|
setVisibleStartIndex(0);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
setIsLoadingSuggestions(false);
|
setIsLoadingSuggestions(false);
|
||||||
|
setIsPerfectMatch(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigateUp = useCallback(() => {
|
const navigateUp = useCallback(() => {
|
||||||
|
@ -127,6 +130,9 @@ export function useCompletion(
|
||||||
const trimmedQuery = query.trimStart();
|
const trimmedQuery = query.trimStart();
|
||||||
|
|
||||||
if (trimmedQuery.startsWith('/')) {
|
if (trimmedQuery.startsWith('/')) {
|
||||||
|
// Always reset perfect match at the beginning of processing.
|
||||||
|
setIsPerfectMatch(false);
|
||||||
|
|
||||||
const fullPath = trimmedQuery.substring(1);
|
const fullPath = trimmedQuery.substring(1);
|
||||||
const hasTrailingSpace = trimmedQuery.endsWith(' ');
|
const hasTrailingSpace = trimmedQuery.endsWith(' ');
|
||||||
|
|
||||||
|
@ -183,6 +189,23 @@ export function useCompletion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for perfect, executable match
|
||||||
|
if (!hasTrailingSpace) {
|
||||||
|
if (leafCommand && partial === '' && leafCommand.action) {
|
||||||
|
// Case: /command<enter> - command has action, no sub-commands were suggested
|
||||||
|
setIsPerfectMatch(true);
|
||||||
|
} else if (currentLevel) {
|
||||||
|
// Case: /command subcommand<enter>
|
||||||
|
const perfectMatch = currentLevel.find(
|
||||||
|
(cmd) =>
|
||||||
|
(cmd.name === partial || cmd.altName === partial) && cmd.action,
|
||||||
|
);
|
||||||
|
if (perfectMatch) {
|
||||||
|
setIsPerfectMatch(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const depth = commandPathParts.length;
|
const depth = commandPathParts.length;
|
||||||
|
|
||||||
// Provide Suggestions based on the now-corrected context
|
// Provide Suggestions based on the now-corrected context
|
||||||
|
@ -223,7 +246,7 @@ export function useCompletion(
|
||||||
const perfectMatch = potentialSuggestions.find(
|
const perfectMatch = potentialSuggestions.find(
|
||||||
(s) => s.name === partial || s.altName === partial,
|
(s) => s.name === partial || s.altName === partial,
|
||||||
);
|
);
|
||||||
if (perfectMatch && !perfectMatch.subCommands) {
|
if (perfectMatch && perfectMatch.action) {
|
||||||
potentialSuggestions = [];
|
potentialSuggestions = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -534,6 +557,7 @@ export function useCompletion(
|
||||||
visibleStartIndex,
|
visibleStartIndex,
|
||||||
showSuggestions,
|
showSuggestions,
|
||||||
isLoadingSuggestions,
|
isLoadingSuggestions,
|
||||||
|
isPerfectMatch,
|
||||||
setActiveSuggestionIndex,
|
setActiveSuggestionIndex,
|
||||||
setShowSuggestions,
|
setShowSuggestions,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
|
|
Loading…
Reference in New Issue