summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/usePromptCompletion.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks/usePromptCompletion.ts')
-rw-r--r--packages/cli/src/ui/hooks/usePromptCompletion.ts253
1 files changed, 253 insertions, 0 deletions
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,
+ };
+}