summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author官余棚 <[email protected]>2025-08-21 16:04:04 +0800
committerGitHub <[email protected]>2025-08-21 08:04:04 +0000
commit589f5e6823eca456d9c93cadea664e5c19eebb90 (patch)
tree031edeb6f5bdf9cac2da206dde9584e04ad186b5
parentba5309c4050efde8b0be0d9dd726e5c5f1a4c4c6 (diff)
feat(cli): prompt completion (#4691)
Co-authored-by: Jacob Richman <[email protected]>
-rw-r--r--packages/cli/src/config/config.ts1
-rw-r--r--packages/cli/src/config/settingsSchema.ts10
-rw-r--r--packages/cli/src/ui/App.test.tsx1
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx5
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx249
-rw-r--r--packages/cli/src/ui/hooks/useCommandCompletion.test.ts6
-rw-r--r--packages/cli/src/ui/hooks/useCommandCompletion.tsx49
-rw-r--r--packages/cli/src/ui/hooks/usePromptCompletion.ts253
-rw-r--r--packages/core/index.ts1
-rw-r--r--packages/core/src/config/config.ts7
-rw-r--r--packages/core/src/index.ts1
11 files changed, 540 insertions, 43 deletions
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 9731b503..0b21ff2e 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -542,6 +542,7 @@ export async function loadCliConfig(
trustedFolder,
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
+ enablePromptCompletion: settings.enablePromptCompletion ?? false,
});
}
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 6d9e1f1e..7f28b698 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -524,6 +524,16 @@ export const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
+ enablePromptCompletion: {
+ type: 'boolean',
+ label: 'Enable Prompt Completion',
+ category: 'General',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Enable AI-powered prompt completion suggestions while typing.',
+ showInDialog: true,
+ },
} as const;
type InferSettings<T extends SettingsSchema> = {
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index f78ec580..9f8a681f 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -147,6 +147,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
getProjectRoot: vi.fn(() => opts.targetDir),
+ getEnablePromptCompletion: vi.fn(() => false),
getGeminiClient: vi.fn(() => ({
getUserTier: vi.fn(),
})),
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 11a0eb48..a3346490 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -160,6 +160,11 @@ describe('InputPrompt', () => {
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
+ promptCompletion: {
+ text: '',
+ accept: vi.fn(),
+ clear: vi.fn(),
+ },
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 02c25bd8..01666c66 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
-import { cpSlice, cpLen } from '../utils/textUtils.js';
+import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
@@ -403,6 +403,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
+ // Handle Tab key for ghost text acceptance
+ if (
+ key.name === 'tab' &&
+ !completion.showSuggestions &&
+ completion.promptCompletion.text
+ ) {
+ completion.promptCompletion.accept();
+ return;
+ }
+
if (!shellModeActive) {
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
@@ -507,6 +517,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
+
+ // Clear ghost text when user types regular characters (not navigation/control keys)
+ if (
+ completion.promptCompletion.text &&
+ key.sequence &&
+ key.sequence.length === 1 &&
+ !key.ctrl &&
+ !key.meta
+ ) {
+ completion.promptCompletion.clear();
+ }
},
[
focus,
@@ -540,6 +561,119 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
+ const getGhostTextLines = useCallback(() => {
+ if (
+ !completion.promptCompletion.text ||
+ !buffer.text ||
+ !completion.promptCompletion.text.startsWith(buffer.text)
+ ) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const ghostSuffix = completion.promptCompletion.text.slice(
+ buffer.text.length,
+ );
+ if (!ghostSuffix) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
+ const cursorCol = buffer.cursor[1];
+
+ const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
+ const usedWidth = stringWidth(textBeforeCursor);
+ const remainingWidth = Math.max(0, inputWidth - usedWidth);
+
+ const ghostTextLinesRaw = ghostSuffix.split('\n');
+ const firstLineRaw = ghostTextLinesRaw.shift() || '';
+
+ let inlineGhost = '';
+ let remainingFirstLine = '';
+
+ if (stringWidth(firstLineRaw) <= remainingWidth) {
+ inlineGhost = firstLineRaw;
+ } else {
+ const words = firstLineRaw.split(' ');
+ let currentLine = '';
+ let wordIdx = 0;
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ if (stringWidth(prospectiveLine) > remainingWidth) {
+ break;
+ }
+ currentLine = prospectiveLine;
+ wordIdx++;
+ }
+ inlineGhost = currentLine;
+ if (words.length > wordIdx) {
+ remainingFirstLine = words.slice(wordIdx).join(' ');
+ }
+ }
+
+ const linesToWrap = [];
+ if (remainingFirstLine) {
+ linesToWrap.push(remainingFirstLine);
+ }
+ linesToWrap.push(...ghostTextLinesRaw);
+ const remainingGhostText = linesToWrap.join('\n');
+
+ const additionalLines: string[] = [];
+ if (remainingGhostText) {
+ const textLines = remainingGhostText.split('\n');
+ for (const textLine of textLines) {
+ const words = textLine.split(' ');
+ let currentLine = '';
+
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ const prospectiveWidth = stringWidth(prospectiveLine);
+
+ if (prospectiveWidth > inputWidth) {
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+
+ let wordToProcess = word;
+ while (stringWidth(wordToProcess) > inputWidth) {
+ let part = '';
+ const wordCP = toCodePoints(wordToProcess);
+ let partWidth = 0;
+ let splitIndex = 0;
+ for (let i = 0; i < wordCP.length; i++) {
+ const char = wordCP[i];
+ const charWidth = stringWidth(char);
+ if (partWidth + charWidth > inputWidth) {
+ break;
+ }
+ part += char;
+ partWidth += charWidth;
+ splitIndex = i + 1;
+ }
+ additionalLines.push(part);
+ wordToProcess = cpSlice(wordToProcess, splitIndex);
+ }
+ currentLine = wordToProcess;
+ } else {
+ currentLine = prospectiveLine;
+ }
+ }
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+ }
+ }
+
+ return { inlineGhost, additionalLines };
+ }, [
+ completion.promptCompletion.text,
+ buffer.text,
+ buffer.lines,
+ buffer.cursor,
+ inputWidth,
+ ]);
+
+ const { inlineGhost, additionalLines } = getGhostTextLines();
+
return (
<>
<Box
@@ -573,42 +707,91 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
- linesToRender.map((lineText, visualIdxInRenderedSet) => {
- const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
- let display = cpSlice(lineText, 0, inputWidth);
- const currentVisualWidth = stringWidth(display);
- if (currentVisualWidth < inputWidth) {
- display = display + ' '.repeat(inputWidth - currentVisualWidth);
- }
+ linesToRender
+ .map((lineText, visualIdxInRenderedSet) => {
+ const cursorVisualRow =
+ cursorVisualRowAbsolute - scrollVisualRow;
+ let display = cpSlice(lineText, 0, inputWidth);
- if (focus && visualIdxInRenderedSet === cursorVisualRow) {
- const relativeVisualColForHighlight = cursorVisualColAbsolute;
+ const isOnCursorLine =
+ focus && visualIdxInRenderedSet === cursorVisualRow;
+ const currentLineGhost = isOnCursorLine ? inlineGhost : '';
- if (relativeVisualColForHighlight >= 0) {
- if (relativeVisualColForHighlight < cpLen(display)) {
- const charToHighlight =
- cpSlice(
- display,
- relativeVisualColForHighlight,
- relativeVisualColForHighlight + 1,
- ) || ' ';
- const highlighted = chalk.inverse(charToHighlight);
- display =
- cpSlice(display, 0, relativeVisualColForHighlight) +
- highlighted +
- cpSlice(display, relativeVisualColForHighlight + 1);
- } else if (
- relativeVisualColForHighlight === cpLen(display) &&
- cpLen(display) === inputWidth
- ) {
- display = display + chalk.inverse(' ');
+ const ghostWidth = stringWidth(currentLineGhost);
+
+ if (focus && visualIdxInRenderedSet === cursorVisualRow) {
+ const relativeVisualColForHighlight = cursorVisualColAbsolute;
+
+ if (relativeVisualColForHighlight >= 0) {
+ if (relativeVisualColForHighlight < cpLen(display)) {
+ const charToHighlight =
+ cpSlice(
+ display,
+ relativeVisualColForHighlight,
+ relativeVisualColForHighlight + 1,
+ ) || ' ';
+ const highlighted = chalk.inverse(charToHighlight);
+ display =
+ cpSlice(display, 0, relativeVisualColForHighlight) +
+ highlighted +
+ cpSlice(display, relativeVisualColForHighlight + 1);
+ } else if (
+ relativeVisualColForHighlight === cpLen(display)
+ ) {
+ if (!currentLineGhost) {
+ display = display + chalk.inverse(' ');
+ }
+ }
}
}
- }
- return (
- <Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
- );
- })
+
+ const showCursorBeforeGhost =
+ focus &&
+ visualIdxInRenderedSet === cursorVisualRow &&
+ cursorVisualColAbsolute ===
+ // eslint-disable-next-line no-control-regex
+ cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
+ currentLineGhost;
+
+ const actualDisplayWidth = stringWidth(display);
+ const cursorWidth = showCursorBeforeGhost ? 1 : 0;
+ const totalContentWidth =
+ actualDisplayWidth + cursorWidth + ghostWidth;
+ const trailingPadding = Math.max(
+ 0,
+ inputWidth - totalContentWidth,
+ );
+
+ return (
+ <Text key={`line-${visualIdxInRenderedSet}`}>
+ {display}
+ {showCursorBeforeGhost && chalk.inverse(' ')}
+ {currentLineGhost && (
+ <Text color={theme.text.secondary}>
+ {currentLineGhost}
+ </Text>
+ )}
+ {trailingPadding > 0 && ' '.repeat(trailingPadding)}
+ </Text>
+ );
+ })
+ .concat(
+ additionalLines.map((ghostLine, index) => {
+ const padding = Math.max(
+ 0,
+ inputWidth - stringWidth(ghostLine),
+ );
+ return (
+ <Text
+ key={`ghost-line-${index}`}
+ color={theme.text.secondary}
+ >
+ {ghostLine}
+ {' '.repeat(padding)}
+ </Text>
+ );
+ }),
+ )
)}
</Box>
</Box>
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,
+ };
+}
diff --git a/packages/core/index.ts b/packages/core/index.ts
index 7b75b365..660306ed 100644
--- a/packages/core/index.ts
+++ b/packages/core/index.ts
@@ -8,6 +8,7 @@ export * from './src/index.js';
export {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
+ DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
} from './src/config/models.js';
export { logIdeConnection } from './src/telemetry/loggers.js';
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 349a0f83..44df13a8 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -200,6 +200,7 @@ export interface ConfigParameters {
trustedFolder?: boolean;
shouldUseNodePtyShell?: boolean;
skipNextSpeakerCheck?: boolean;
+ enablePromptCompletion?: boolean;
}
export class Config {
@@ -267,6 +268,7 @@ export class Config {
private readonly trustedFolder: boolean | undefined;
private readonly shouldUseNodePtyShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
+ private readonly enablePromptCompletion: boolean = false;
private initialized: boolean = false;
readonly storage: Storage;
@@ -338,6 +340,7 @@ export class Config {
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
this.storage = new Storage(this.targetDir);
+ this.enablePromptCompletion = params.enablePromptCompletion ?? false;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -731,6 +734,10 @@ export class Config {
return this.skipNextSpeakerCheck;
}
+ getEnablePromptCompletion(): boolean {
+ return this.enablePromptCompletion;
+ }
+
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index f8cd08a8..afdba8fc 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -41,6 +41,7 @@ export * from './utils/shell-utils.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
export * from './utils/formatters.js';
+export * from './utils/generateContentResponseUtilities.js';
export * from './utils/filesearch/fileSearch.js';
export * from './utils/errorParsing.js';