diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index e1d68125..1a100c36 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -9,7 +9,7 @@ import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@google/gemini-cli-core';
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 { useCompletion } from '../hooks/useCompletion.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
mockedUseCompletion.mockReturnValue({
...mockCompletion,
@@ -357,12 +357,12 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
- expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory');
unmount();
});
- it('should append a sub-command when the parent command is already complete with a space', async () => {
- // SCENARIO: /memory -> Tab (to accept 'add')
+ it('should append a sub-command when the parent command is already complete', async () => {
+ // SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
@@ -380,13 +380,12 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
- expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory add');
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
- // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
- // This is the critical bug we fixed.
+ // SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
showSuggestions: true,
@@ -405,8 +404,8 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
- // It should NOT become '/show '. It should correctly become '/memory show '.
- expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
+ // It should NOT become '/show'. It should correctly become '/memory show'.
+ expect(props.buffer.setText).toHaveBeenCalledWith('/memory show');
unmount();
});
@@ -426,7 +425,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
- expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
+ expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo');
unmount();
});
@@ -446,7 +445,7 @@ describe('InputPrompt', () => {
await wait();
// 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();
unmount();
@@ -471,10 +470,10 @@ describe('InputPrompt', () => {
const { stdin, unmount } = render();
await wait();
- stdin.write('\t'); // Press Tab
+ stdin.write('\t'); // Press Tab for autocomplete
await wait();
- expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
+ expect(props.buffer.setText).toHaveBeenCalledWith('/help');
unmount();
});
@@ -505,7 +504,6 @@ describe('InputPrompt', () => {
await wait();
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
- expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
unmount();
});
@@ -530,7 +528,10 @@ describe('InputPrompt', () => {
});
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();
await wait();
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 01ed8db1..4d66b10c 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -162,7 +162,7 @@ export const InputPrompt: React.FC = ({
// - Otherwise, the base is everything EXCEPT the last partial part.
const basePath =
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
- const newValue = `/${[...basePath, suggestion].join(' ')} `;
+ const newValue = `/${[...basePath, suggestion].join(' ')}`;
buffer.setText(newValue);
} else {
@@ -266,6 +266,12 @@ export const InputPrompt: React.FC = ({
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.suggestions.length > 1) {
if (key.name === 'up') {
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index 7f2823c7..267bce13 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+/** @vitest-environment jsdom */
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import type { Mocked } from 'vitest';
import { renderHook, act } from '@testing-library/react';
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index d3de5c6b..81acc992 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -28,6 +28,7 @@ export interface UseCompletionReturn {
visibleStartIndex: number;
showSuggestions: boolean;
isLoadingSuggestions: boolean;
+ isPerfectMatch: boolean;
setActiveSuggestionIndex: React.Dispatch>;
setShowSuggestions: React.Dispatch>;
resetCompletionState: () => void;
@@ -50,6 +51,7 @@ export function useCompletion(
const [showSuggestions, setShowSuggestions] = useState(false);
const [isLoadingSuggestions, setIsLoadingSuggestions] =
useState(false);
+ const [isPerfectMatch, setIsPerfectMatch] = useState(false);
const resetCompletionState = useCallback(() => {
setSuggestions([]);
@@ -57,6 +59,7 @@ export function useCompletion(
setVisibleStartIndex(0);
setShowSuggestions(false);
setIsLoadingSuggestions(false);
+ setIsPerfectMatch(false);
}, []);
const navigateUp = useCallback(() => {
@@ -127,6 +130,9 @@ export function useCompletion(
const trimmedQuery = query.trimStart();
if (trimmedQuery.startsWith('/')) {
+ // Always reset perfect match at the beginning of processing.
+ setIsPerfectMatch(false);
+
const fullPath = trimmedQuery.substring(1);
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 - command has action, no sub-commands were suggested
+ setIsPerfectMatch(true);
+ } else if (currentLevel) {
+ // Case: /command subcommand
+ const perfectMatch = currentLevel.find(
+ (cmd) =>
+ (cmd.name === partial || cmd.altName === partial) && cmd.action,
+ );
+ if (perfectMatch) {
+ setIsPerfectMatch(true);
+ }
+ }
+ }
+
const depth = commandPathParts.length;
// Provide Suggestions based on the now-corrected context
@@ -223,7 +246,7 @@ export function useCompletion(
const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial || s.altName === partial,
);
- if (perfectMatch && !perfectMatch.subCommands) {
+ if (perfectMatch && perfectMatch.action) {
potentialSuggestions = [];
}
}
@@ -534,6 +557,7 @@ export function useCompletion(
visibleStartIndex,
showSuggestions,
isLoadingSuggestions,
+ isPerfectMatch,
setActiveSuggestionIndex,
setShowSuggestions,
resetCompletionState,