mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -07:00
feat(cli): prompt completion (#4691)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
253
packages/cli/src/ui/hooks/usePromptCompletion.ts
Normal file
253
packages/cli/src/ui/hooks/usePromptCompletion.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user