From de27ea6095f3f36f8939af0aaeaf9362e3c61490 Mon Sep 17 00:00:00 2001 From: Harold Mciver Date: Fri, 18 Jul 2025 00:55:29 -0400 Subject: [PATCH] feat(cli): allow executing commands on perfect match (#4397) Co-authored-by: Jenna Inouye --- .../src/ui/components/InputPrompt.test.tsx | 33 ++++++++++--------- .../cli/src/ui/components/InputPrompt.tsx | 8 ++++- .../cli/src/ui/hooks/useCompletion.test.ts | 2 ++ packages/cli/src/ui/hooks/useCompletion.ts | 26 ++++++++++++++- 4 files changed, 51 insertions(+), 18 deletions(-) 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,