diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/useCommandCompletion.test.ts | 6 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useCommandCompletion.tsx | 49 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/usePromptCompletion.ts | 253 |
3 files changed, 298 insertions, 10 deletions
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index a3c96935..00bc8ac3 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -84,7 +84,9 @@ const setupMocks = ({ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; - const mockConfig = {} as Config; + const mockConfig = { + getEnablePromptCompletion: () => false, + } as Config; const testDirs: string[] = []; const testRootDir = '/'; @@ -511,7 +513,7 @@ describe('useCommandCompletion', () => { }); expect(result.current.textBuffer.text).toBe( - '@src/file1.txt is a good file', + '@src/file1.txt is a good file', ); }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 07d0e056..166f03c5 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -15,6 +15,11 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; +import { + usePromptCompletion, + PromptCompletion, + PROMPT_COMPLETION_MIN_LENGTH, +} from './usePromptCompletion.js'; import { Config } from '@google/gemini-cli-core'; import { useCompletion } from './useCompletion.js'; @@ -22,6 +27,7 @@ export enum CompletionMode { IDLE = 'IDLE', AT = 'AT', SLASH = 'SLASH', + PROMPT = 'PROMPT', } export interface UseCommandCompletionReturn { @@ -37,6 +43,7 @@ export interface UseCommandCompletionReturn { navigateUp: () => void; navigateDown: () => void; handleAutocomplete: (indexToUse: number) => void; + promptCompletion: PromptCompletion; } export function useCommandCompletion( @@ -93,12 +100,7 @@ export function useCommandCompletion( backslashCount++; } if (backslashCount % 2 === 0) { - return { - completionMode: CompletionMode.IDLE, - query: null, - completionStart: -1, - completionEnd: -1, - }; + break; } } else if (char === '@') { let end = codePoints.length; @@ -125,13 +127,33 @@ export function useCommandCompletion( }; } } + + // Check for prompt completion - only if enabled + const trimmedText = buffer.text.trim(); + const isPromptCompletionEnabled = + config?.getEnablePromptCompletion() ?? false; + + if ( + isPromptCompletionEnabled && + trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && + !trimmedText.startsWith('/') && + !trimmedText.includes('@') + ) { + return { + completionMode: CompletionMode.PROMPT, + query: trimmedText, + completionStart: 0, + completionEnd: trimmedText.length, + }; + } + return { completionMode: CompletionMode.IDLE, query: null, completionStart: -1, completionEnd: -1, }; - }, [cursorRow, cursorCol, buffer.lines]); + }, [cursorRow, cursorCol, buffer.lines, buffer.text, config]); useAtCompletion({ enabled: completionMode === CompletionMode.AT, @@ -152,6 +174,12 @@ export function useCommandCompletion( setIsPerfectMatch, }); + const promptCompletion = usePromptCompletion({ + buffer, + config, + enabled: completionMode === CompletionMode.PROMPT, + }); + useEffect(() => { setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); setVisibleStartIndex(0); @@ -202,7 +230,11 @@ export function useCommandCompletion( } } - suggestionText += ' '; + const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || ''); + const charAfterCompletion = lineCodePoints[end]; + if (charAfterCompletion !== ' ') { + suggestionText += ' '; + } buffer.replaceRangeByOffset( logicalPosToOffset(buffer.lines, cursorRow, start), @@ -234,5 +266,6 @@ export function useCommandCompletion( navigateUp, navigateDown, handleAutocomplete, + promptCompletion, }; } diff --git a/packages/cli/src/ui/hooks/usePromptCompletion.ts b/packages/cli/src/ui/hooks/usePromptCompletion.ts new file mode 100644 index 00000000..466d020b --- /dev/null +++ b/packages/cli/src/ui/hooks/usePromptCompletion.ts @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { + Config, + DEFAULT_GEMINI_FLASH_LITE_MODEL, + getResponseText, +} from '@google/gemini-cli-core'; +import { Content, GenerateContentConfig } from '@google/genai'; +import { TextBuffer } from '../components/shared/text-buffer.js'; + +export const PROMPT_COMPLETION_MIN_LENGTH = 5; +export const PROMPT_COMPLETION_DEBOUNCE_MS = 250; + +export interface PromptCompletion { + text: string; + isLoading: boolean; + isActive: boolean; + accept: () => void; + clear: () => void; + markSelected: (selectedText: string) => void; +} + +export interface UsePromptCompletionOptions { + buffer: TextBuffer; + config?: Config; + enabled: boolean; +} + +export function usePromptCompletion({ + buffer, + config, + enabled, +}: UsePromptCompletionOptions): PromptCompletion { + const [ghostText, setGhostText] = useState<string>(''); + const [isLoadingGhostText, setIsLoadingGhostText] = useState<boolean>(false); + const abortControllerRef = useRef<AbortController | null>(null); + const [justSelectedSuggestion, setJustSelectedSuggestion] = + useState<boolean>(false); + const lastSelectedTextRef = useRef<string>(''); + const lastRequestedTextRef = useRef<string>(''); + + const isPromptCompletionEnabled = + enabled && (config?.getEnablePromptCompletion() ?? false); + + const clearGhostText = useCallback(() => { + setGhostText(''); + setIsLoadingGhostText(false); + }, []); + + const acceptGhostText = useCallback(() => { + if (ghostText && ghostText.length > buffer.text.length) { + buffer.setText(ghostText); + setGhostText(''); + setJustSelectedSuggestion(true); + lastSelectedTextRef.current = ghostText; + } + }, [ghostText, buffer]); + + const markSuggestionSelected = useCallback((selectedText: string) => { + setJustSelectedSuggestion(true); + lastSelectedTextRef.current = selectedText; + }, []); + + const generatePromptSuggestions = useCallback(async () => { + const trimmedText = buffer.text.trim(); + const geminiClient = config?.getGeminiClient(); + + if (trimmedText === lastRequestedTextRef.current) { + return; + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + if ( + trimmedText.length < PROMPT_COMPLETION_MIN_LENGTH || + !geminiClient || + trimmedText.startsWith('/') || + trimmedText.includes('@') || + !isPromptCompletionEnabled + ) { + clearGhostText(); + lastRequestedTextRef.current = ''; + return; + } + + lastRequestedTextRef.current = trimmedText; + setIsLoadingGhostText(true); + + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + try { + const contents: Content[] = [ + { + role: 'user', + parts: [ + { + text: `You are a professional prompt engineering assistant. Complete the user's partial prompt with expert precision and clarity. User's input: "${trimmedText}" Continue this prompt by adding specific, actionable details that align with the user's intent. Focus on: clear, precise language; structured requirements; professional terminology; measurable outcomes. Length Guidelines: Keep suggestions concise (ideally 10-20 characters); prioritize brevity while maintaining clarity; use essential keywords only; avoid redundant phrases. Start your response with the exact user text ("${trimmedText}") followed by your completion. Provide practical, implementation-focused suggestions rather than creative interpretations. Format: Plain text only. Single completion. Match the user's language. Emphasize conciseness over elaboration.`, + }, + ], + }, + ]; + + const generationConfig: GenerateContentConfig = { + temperature: 0.3, + maxOutputTokens: 16000, + thinkingConfig: { + thinkingBudget: 0, + }, + }; + + const response = await geminiClient.generateContent( + contents, + generationConfig, + signal, + DEFAULT_GEMINI_FLASH_LITE_MODEL, + ); + + if (signal.aborted) { + return; + } + + if (response) { + const responseText = getResponseText(response); + + if (responseText) { + const suggestionText = responseText.trim(); + + if ( + suggestionText.length > 0 && + suggestionText.startsWith(trimmedText) + ) { + setGhostText(suggestionText); + } else { + clearGhostText(); + } + } + } + } catch (error) { + if ( + !( + signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) + ) { + console.error('prompt completion error:', error); + // Clear the last requested text to allow retry only on real errors + lastRequestedTextRef.current = ''; + } + clearGhostText(); + } finally { + if (!signal.aborted) { + setIsLoadingGhostText(false); + } + } + }, [buffer.text, config, clearGhostText, isPromptCompletionEnabled]); + + const isCursorAtEnd = useCallback(() => { + const [cursorRow, cursorCol] = buffer.cursor; + const totalLines = buffer.lines.length; + if (cursorRow !== totalLines - 1) { + return false; + } + + const lastLine = buffer.lines[cursorRow] || ''; + return cursorCol === lastLine.length; + }, [buffer.cursor, buffer.lines]); + + const handlePromptCompletion = useCallback(() => { + if (!isCursorAtEnd()) { + clearGhostText(); + return; + } + + const trimmedText = buffer.text.trim(); + + if (justSelectedSuggestion && trimmedText === lastSelectedTextRef.current) { + return; + } + + if (trimmedText !== lastSelectedTextRef.current) { + setJustSelectedSuggestion(false); + lastSelectedTextRef.current = ''; + } + + generatePromptSuggestions(); + }, [ + buffer.text, + generatePromptSuggestions, + justSelectedSuggestion, + isCursorAtEnd, + clearGhostText, + ]); + + // Debounce prompt completion + useEffect(() => { + const timeoutId = setTimeout( + handlePromptCompletion, + PROMPT_COMPLETION_DEBOUNCE_MS, + ); + return () => clearTimeout(timeoutId); + }, [buffer.text, buffer.cursor, handlePromptCompletion]); + + // Ghost text validation - clear if it doesn't match current text or cursor not at end + useEffect(() => { + const currentText = buffer.text.trim(); + + if (ghostText && !isCursorAtEnd()) { + clearGhostText(); + return; + } + + if ( + ghostText && + currentText.length > 0 && + !ghostText.startsWith(currentText) + ) { + clearGhostText(); + } + }, [buffer.text, buffer.cursor, ghostText, clearGhostText, isCursorAtEnd]); + + // Cleanup on unmount + useEffect(() => () => abortControllerRef.current?.abort(), []); + + const isActive = useMemo(() => { + if (!isPromptCompletionEnabled) return false; + + if (!isCursorAtEnd()) return false; + + const trimmedText = buffer.text.trim(); + return ( + trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && + !trimmedText.startsWith('/') && + !trimmedText.includes('@') + ); + }, [buffer.text, isPromptCompletionEnabled, isCursorAtEnd]); + + return { + text: ghostText, + isLoading: isLoadingGhostText, + isActive, + accept: acceptGhostText, + clear: clearGhostText, + markSelected: markSuggestionSelected, + }; +} |
