feat(cli): prompt completion (#4691)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
ba5309c405
commit
589f5e6823
|
@ -542,6 +542,7 @@ export async function loadCliConfig(
|
||||||
trustedFolder,
|
trustedFolder,
|
||||||
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
|
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
|
||||||
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
|
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
|
||||||
|
enablePromptCompletion: settings.enablePromptCompletion ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -524,6 +524,16 @@ export const SETTINGS_SCHEMA = {
|
||||||
description: 'Skip the next speaker check.',
|
description: 'Skip the next speaker check.',
|
||||||
showInDialog: true,
|
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;
|
} as const;
|
||||||
|
|
||||||
type InferSettings<T extends SettingsSchema> = {
|
type InferSettings<T extends SettingsSchema> = {
|
||||||
|
|
|
@ -147,6 +147,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
||||||
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
||||||
getProjectRoot: vi.fn(() => opts.targetDir),
|
getProjectRoot: vi.fn(() => opts.targetDir),
|
||||||
|
getEnablePromptCompletion: vi.fn(() => false),
|
||||||
getGeminiClient: vi.fn(() => ({
|
getGeminiClient: vi.fn(() => ({
|
||||||
getUserTier: vi.fn(),
|
getUserTier: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
|
|
@ -160,6 +160,11 @@ describe('InputPrompt', () => {
|
||||||
setActiveSuggestionIndex: vi.fn(),
|
setActiveSuggestionIndex: vi.fn(),
|
||||||
setShowSuggestions: vi.fn(),
|
setShowSuggestions: vi.fn(),
|
||||||
handleAutocomplete: vi.fn(),
|
handleAutocomplete: vi.fn(),
|
||||||
|
promptCompletion: {
|
||||||
|
text: '',
|
||||||
|
accept: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
|
||||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.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 chalk from 'chalk';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
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 (!shellModeActive) {
|
||||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||||
inputHistory.navigateUp();
|
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
|
// Fall back to the text buffer's default input handling for all other keys
|
||||||
buffer.handleInput(key);
|
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,
|
focus,
|
||||||
|
@ -540,6 +561,119 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
buffer.visualCursor;
|
buffer.visualCursor;
|
||||||
const scrollVisualRow = buffer.visualScrollRow;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
|
@ -573,13 +707,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
linesToRender
|
||||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
.map((lineText, visualIdxInRenderedSet) => {
|
||||||
|
const cursorVisualRow =
|
||||||
|
cursorVisualRowAbsolute - scrollVisualRow;
|
||||||
let display = cpSlice(lineText, 0, inputWidth);
|
let display = cpSlice(lineText, 0, inputWidth);
|
||||||
const currentVisualWidth = stringWidth(display);
|
|
||||||
if (currentVisualWidth < inputWidth) {
|
const isOnCursorLine =
|
||||||
display = display + ' '.repeat(inputWidth - currentVisualWidth);
|
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||||
}
|
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||||
|
|
||||||
|
const ghostWidth = stringWidth(currentLineGhost);
|
||||||
|
|
||||||
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
||||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||||
|
@ -598,17 +736,62 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
highlighted +
|
highlighted +
|
||||||
cpSlice(display, relativeVisualColForHighlight + 1);
|
cpSlice(display, relativeVisualColForHighlight + 1);
|
||||||
} else if (
|
} else if (
|
||||||
relativeVisualColForHighlight === cpLen(display) &&
|
relativeVisualColForHighlight === cpLen(display)
|
||||||
cpLen(display) === inputWidth
|
|
||||||
) {
|
) {
|
||||||
|
if (!currentLineGhost) {
|
||||||
display = display + chalk.inverse(' ');
|
display = display + chalk.inverse(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -84,7 +84,9 @@ const setupMocks = ({
|
||||||
|
|
||||||
describe('useCommandCompletion', () => {
|
describe('useCommandCompletion', () => {
|
||||||
const mockCommandContext = {} as CommandContext;
|
const mockCommandContext = {} as CommandContext;
|
||||||
const mockConfig = {} as Config;
|
const mockConfig = {
|
||||||
|
getEnablePromptCompletion: () => false,
|
||||||
|
} as Config;
|
||||||
const testDirs: string[] = [];
|
const testDirs: string[] = [];
|
||||||
const testRootDir = '/';
|
const testRootDir = '/';
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,11 @@ import { isSlashCommand } from '../utils/commandUtils.js';
|
||||||
import { toCodePoints } from '../utils/textUtils.js';
|
import { toCodePoints } from '../utils/textUtils.js';
|
||||||
import { useAtCompletion } from './useAtCompletion.js';
|
import { useAtCompletion } from './useAtCompletion.js';
|
||||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||||
|
import {
|
||||||
|
usePromptCompletion,
|
||||||
|
PromptCompletion,
|
||||||
|
PROMPT_COMPLETION_MIN_LENGTH,
|
||||||
|
} from './usePromptCompletion.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
import { useCompletion } from './useCompletion.js';
|
import { useCompletion } from './useCompletion.js';
|
||||||
|
|
||||||
|
@ -22,6 +27,7 @@ export enum CompletionMode {
|
||||||
IDLE = 'IDLE',
|
IDLE = 'IDLE',
|
||||||
AT = 'AT',
|
AT = 'AT',
|
||||||
SLASH = 'SLASH',
|
SLASH = 'SLASH',
|
||||||
|
PROMPT = 'PROMPT',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCommandCompletionReturn {
|
export interface UseCommandCompletionReturn {
|
||||||
|
@ -37,6 +43,7 @@ export interface UseCommandCompletionReturn {
|
||||||
navigateUp: () => void;
|
navigateUp: () => void;
|
||||||
navigateDown: () => void;
|
navigateDown: () => void;
|
||||||
handleAutocomplete: (indexToUse: number) => void;
|
handleAutocomplete: (indexToUse: number) => void;
|
||||||
|
promptCompletion: PromptCompletion;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommandCompletion(
|
export function useCommandCompletion(
|
||||||
|
@ -93,12 +100,7 @@ export function useCommandCompletion(
|
||||||
backslashCount++;
|
backslashCount++;
|
||||||
}
|
}
|
||||||
if (backslashCount % 2 === 0) {
|
if (backslashCount % 2 === 0) {
|
||||||
return {
|
break;
|
||||||
completionMode: CompletionMode.IDLE,
|
|
||||||
query: null,
|
|
||||||
completionStart: -1,
|
|
||||||
completionEnd: -1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} else if (char === '@') {
|
} else if (char === '@') {
|
||||||
let end = codePoints.length;
|
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 {
|
return {
|
||||||
completionMode: CompletionMode.IDLE,
|
completionMode: CompletionMode.IDLE,
|
||||||
query: null,
|
query: null,
|
||||||
completionStart: -1,
|
completionStart: -1,
|
||||||
completionEnd: -1,
|
completionEnd: -1,
|
||||||
};
|
};
|
||||||
}, [cursorRow, cursorCol, buffer.lines]);
|
}, [cursorRow, cursorCol, buffer.lines, buffer.text, config]);
|
||||||
|
|
||||||
useAtCompletion({
|
useAtCompletion({
|
||||||
enabled: completionMode === CompletionMode.AT,
|
enabled: completionMode === CompletionMode.AT,
|
||||||
|
@ -152,6 +174,12 @@ export function useCommandCompletion(
|
||||||
setIsPerfectMatch,
|
setIsPerfectMatch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const promptCompletion = usePromptCompletion({
|
||||||
|
buffer,
|
||||||
|
config,
|
||||||
|
enabled: completionMode === CompletionMode.PROMPT,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
|
||||||
setVisibleStartIndex(0);
|
setVisibleStartIndex(0);
|
||||||
|
@ -202,7 +230,11 @@ export function useCommandCompletion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
|
||||||
|
const charAfterCompletion = lineCodePoints[end];
|
||||||
|
if (charAfterCompletion !== ' ') {
|
||||||
suggestionText += ' ';
|
suggestionText += ' ';
|
||||||
|
}
|
||||||
|
|
||||||
buffer.replaceRangeByOffset(
|
buffer.replaceRangeByOffset(
|
||||||
logicalPosToOffset(buffer.lines, cursorRow, start),
|
logicalPosToOffset(buffer.lines, cursorRow, start),
|
||||||
|
@ -234,5 +266,6 @@ export function useCommandCompletion(
|
||||||
navigateUp,
|
navigateUp,
|
||||||
navigateDown,
|
navigateDown,
|
||||||
handleAutocomplete,
|
handleAutocomplete,
|
||||||
|
promptCompletion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ export * from './src/index.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_GEMINI_MODEL,
|
DEFAULT_GEMINI_MODEL,
|
||||||
DEFAULT_GEMINI_FLASH_MODEL,
|
DEFAULT_GEMINI_FLASH_MODEL,
|
||||||
|
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||||
} from './src/config/models.js';
|
} from './src/config/models.js';
|
||||||
export { logIdeConnection } from './src/telemetry/loggers.js';
|
export { logIdeConnection } from './src/telemetry/loggers.js';
|
||||||
|
|
|
@ -200,6 +200,7 @@ export interface ConfigParameters {
|
||||||
trustedFolder?: boolean;
|
trustedFolder?: boolean;
|
||||||
shouldUseNodePtyShell?: boolean;
|
shouldUseNodePtyShell?: boolean;
|
||||||
skipNextSpeakerCheck?: boolean;
|
skipNextSpeakerCheck?: boolean;
|
||||||
|
enablePromptCompletion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
@ -267,6 +268,7 @@ export class Config {
|
||||||
private readonly trustedFolder: boolean | undefined;
|
private readonly trustedFolder: boolean | undefined;
|
||||||
private readonly shouldUseNodePtyShell: boolean;
|
private readonly shouldUseNodePtyShell: boolean;
|
||||||
private readonly skipNextSpeakerCheck: boolean;
|
private readonly skipNextSpeakerCheck: boolean;
|
||||||
|
private readonly enablePromptCompletion: boolean = false;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
readonly storage: Storage;
|
readonly storage: Storage;
|
||||||
|
|
||||||
|
@ -338,6 +340,7 @@ export class Config {
|
||||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
||||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
||||||
this.storage = new Storage(this.targetDir);
|
this.storage = new Storage(this.targetDir);
|
||||||
|
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
|
@ -731,6 +734,10 @@ export class Config {
|
||||||
return this.skipNextSpeakerCheck;
|
return this.skipNextSpeakerCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnablePromptCompletion(): boolean {
|
||||||
|
return this.enablePromptCompletion;
|
||||||
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
if (!this.gitService) {
|
if (!this.gitService) {
|
||||||
this.gitService = new GitService(this.targetDir, this.storage);
|
this.gitService = new GitService(this.targetDir, this.storage);
|
||||||
|
|
|
@ -41,6 +41,7 @@ export * from './utils/shell-utils.js';
|
||||||
export * from './utils/systemEncoding.js';
|
export * from './utils/systemEncoding.js';
|
||||||
export * from './utils/textUtils.js';
|
export * from './utils/textUtils.js';
|
||||||
export * from './utils/formatters.js';
|
export * from './utils/formatters.js';
|
||||||
|
export * from './utils/generateContentResponseUtilities.js';
|
||||||
export * from './utils/filesearch/fileSearch.js';
|
export * from './utils/filesearch/fileSearch.js';
|
||||||
export * from './utils/errorParsing.js';
|
export * from './utils/errorParsing.js';
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue