Skip to content

Commit 589f5e6

Browse files
3ksjacob314
andauthored
feat(cli): prompt completion (google-gemini#4691)
Co-authored-by: Jacob Richman <[email protected]>
1 parent ba5309c commit 589f5e6

File tree

11 files changed

+542
-45
lines changed

11 files changed

+542
-45
lines changed

packages/cli/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ export async function loadCliConfig(
542542
trustedFolder,
543543
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
544544
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
545+
enablePromptCompletion: settings.enablePromptCompletion ?? false,
545546
});
546547
}
547548

packages/cli/src/config/settingsSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,16 @@ export const SETTINGS_SCHEMA = {
524524
description: 'Skip the next speaker check.',
525525
showInDialog: true,
526526
},
527+
enablePromptCompletion: {
528+
type: 'boolean',
529+
label: 'Enable Prompt Completion',
530+
category: 'General',
531+
requiresRestart: true,
532+
default: false,
533+
description:
534+
'Enable AI-powered prompt completion suggestions while typing.',
535+
showInDialog: true,
536+
},
527537
} as const;
528538

529539
type InferSettings<T extends SettingsSchema> = {

packages/cli/src/ui/App.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
147147
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
148148
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
149149
getProjectRoot: vi.fn(() => opts.targetDir),
150+
getEnablePromptCompletion: vi.fn(() => false),
150151
getGeminiClient: vi.fn(() => ({
151152
getUserTier: vi.fn(),
152153
})),

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ describe('InputPrompt', () => {
160160
setActiveSuggestionIndex: vi.fn(),
161161
setShowSuggestions: vi.fn(),
162162
handleAutocomplete: vi.fn(),
163+
promptCompletion: {
164+
text: '',
165+
accept: vi.fn(),
166+
clear: vi.fn(),
167+
},
163168
};
164169
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
165170

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 218 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { theme } from '../semantic-colors.js';
1010
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
1111
import { useInputHistory } from '../hooks/useInputHistory.js';
1212
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
13-
import { cpSlice, cpLen } from '../utils/textUtils.js';
13+
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
1414
import chalk from 'chalk';
1515
import stringWidth from 'string-width';
1616
import { useShellHistory } from '../hooks/useShellHistory.js';
@@ -403,6 +403,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
403403
}
404404
}
405405

406+
// Handle Tab key for ghost text acceptance
407+
if (
408+
key.name === 'tab' &&
409+
!completion.showSuggestions &&
410+
completion.promptCompletion.text
411+
) {
412+
completion.promptCompletion.accept();
413+
return;
414+
}
415+
406416
if (!shellModeActive) {
407417
if (keyMatchers[Command.HISTORY_UP](key)) {
408418
inputHistory.navigateUp();
@@ -507,6 +517,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
507517

508518
// Fall back to the text buffer's default input handling for all other keys
509519
buffer.handleInput(key);
520+
521+
// Clear ghost text when user types regular characters (not navigation/control keys)
522+
if (
523+
completion.promptCompletion.text &&
524+
key.sequence &&
525+
key.sequence.length === 1 &&
526+
!key.ctrl &&
527+
!key.meta
528+
) {
529+
completion.promptCompletion.clear();
530+
}
510531
},
511532
[
512533
focus,
@@ -540,6 +561,119 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
540561
buffer.visualCursor;
541562
const scrollVisualRow = buffer.visualScrollRow;
542563

564+
const getGhostTextLines = useCallback(() => {
565+
if (
566+
!completion.promptCompletion.text ||
567+
!buffer.text ||
568+
!completion.promptCompletion.text.startsWith(buffer.text)
569+
) {
570+
return { inlineGhost: '', additionalLines: [] };
571+
}
572+
573+
const ghostSuffix = completion.promptCompletion.text.slice(
574+
buffer.text.length,
575+
);
576+
if (!ghostSuffix) {
577+
return { inlineGhost: '', additionalLines: [] };
578+
}
579+
580+
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
581+
const cursorCol = buffer.cursor[1];
582+
583+
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
584+
const usedWidth = stringWidth(textBeforeCursor);
585+
const remainingWidth = Math.max(0, inputWidth - usedWidth);
586+
587+
const ghostTextLinesRaw = ghostSuffix.split('\n');
588+
const firstLineRaw = ghostTextLinesRaw.shift() || '';
589+
590+
let inlineGhost = '';
591+
let remainingFirstLine = '';
592+
593+
if (stringWidth(firstLineRaw) <= remainingWidth) {
594+
inlineGhost = firstLineRaw;
595+
} else {
596+
const words = firstLineRaw.split(' ');
597+
let currentLine = '';
598+
let wordIdx = 0;
599+
for (const word of words) {
600+
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
601+
if (stringWidth(prospectiveLine) > remainingWidth) {
602+
break;
603+
}
604+
currentLine = prospectiveLine;
605+
wordIdx++;
606+
}
607+
inlineGhost = currentLine;
608+
if (words.length > wordIdx) {
609+
remainingFirstLine = words.slice(wordIdx).join(' ');
610+
}
611+
}
612+
613+
const linesToWrap = [];
614+
if (remainingFirstLine) {
615+
linesToWrap.push(remainingFirstLine);
616+
}
617+
linesToWrap.push(...ghostTextLinesRaw);
618+
const remainingGhostText = linesToWrap.join('\n');
619+
620+
const additionalLines: string[] = [];
621+
if (remainingGhostText) {
622+
const textLines = remainingGhostText.split('\n');
623+
for (const textLine of textLines) {
624+
const words = textLine.split(' ');
625+
let currentLine = '';
626+
627+
for (const word of words) {
628+
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
629+
const prospectiveWidth = stringWidth(prospectiveLine);
630+
631+
if (prospectiveWidth > inputWidth) {
632+
if (currentLine) {
633+
additionalLines.push(currentLine);
634+
}
635+
636+
let wordToProcess = word;
637+
while (stringWidth(wordToProcess) > inputWidth) {
638+
let part = '';
639+
const wordCP = toCodePoints(wordToProcess);
640+
let partWidth = 0;
641+
let splitIndex = 0;
642+
for (let i = 0; i < wordCP.length; i++) {
643+
const char = wordCP[i];
644+
const charWidth = stringWidth(char);
645+
if (partWidth + charWidth > inputWidth) {
646+
break;
647+
}
648+
part += char;
649+
partWidth += charWidth;
650+
splitIndex = i + 1;
651+
}
652+
additionalLines.push(part);
653+
wordToProcess = cpSlice(wordToProcess, splitIndex);
654+
}
655+
currentLine = wordToProcess;
656+
} else {
657+
currentLine = prospectiveLine;
658+
}
659+
}
660+
if (currentLine) {
661+
additionalLines.push(currentLine);
662+
}
663+
}
664+
}
665+
666+
return { inlineGhost, additionalLines };
667+
}, [
668+
completion.promptCompletion.text,
669+
buffer.text,
670+
buffer.lines,
671+
buffer.cursor,
672+
inputWidth,
673+
]);
674+
675+
const { inlineGhost, additionalLines } = getGhostTextLines();
676+
543677
return (
544678
<>
545679
<Box
@@ -573,42 +707,91 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
573707
<Text color={theme.text.secondary}>{placeholder}</Text>
574708
)
575709
) : (
576-
linesToRender.map((lineText, visualIdxInRenderedSet) => {
577-
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
578-
let display = cpSlice(lineText, 0, inputWidth);
579-
const currentVisualWidth = stringWidth(display);
580-
if (currentVisualWidth < inputWidth) {
581-
display = display + ' '.repeat(inputWidth - currentVisualWidth);
582-
}
583-
584-
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
585-
const relativeVisualColForHighlight = cursorVisualColAbsolute;
586-
587-
if (relativeVisualColForHighlight >= 0) {
588-
if (relativeVisualColForHighlight < cpLen(display)) {
589-
const charToHighlight =
590-
cpSlice(
591-
display,
592-
relativeVisualColForHighlight,
593-
relativeVisualColForHighlight + 1,
594-
) || ' ';
595-
const highlighted = chalk.inverse(charToHighlight);
596-
display =
597-
cpSlice(display, 0, relativeVisualColForHighlight) +
598-
highlighted +
599-
cpSlice(display, relativeVisualColForHighlight + 1);
600-
} else if (
601-
relativeVisualColForHighlight === cpLen(display) &&
602-
cpLen(display) === inputWidth
603-
) {
604-
display = display + chalk.inverse(' ');
710+
linesToRender
711+
.map((lineText, visualIdxInRenderedSet) => {
712+
const cursorVisualRow =
713+
cursorVisualRowAbsolute - scrollVisualRow;
714+
let display = cpSlice(lineText, 0, inputWidth);
715+
716+
const isOnCursorLine =
717+
focus && visualIdxInRenderedSet === cursorVisualRow;
718+
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
719+
720+
const ghostWidth = stringWidth(currentLineGhost);
721+
722+
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
723+
const relativeVisualColForHighlight = cursorVisualColAbsolute;
724+
725+
if (relativeVisualColForHighlight >= 0) {
726+
if (relativeVisualColForHighlight < cpLen(display)) {
727+
const charToHighlight =
728+
cpSlice(
729+
display,
730+
relativeVisualColForHighlight,
731+
relativeVisualColForHighlight + 1,
732+
) || ' ';
733+
const highlighted = chalk.inverse(charToHighlight);
734+
display =
735+
cpSlice(display, 0, relativeVisualColForHighlight) +
736+
highlighted +
737+
cpSlice(display, relativeVisualColForHighlight + 1);
738+
} else if (
739+
relativeVisualColForHighlight === cpLen(display)
740+
) {
741+
if (!currentLineGhost) {
742+
display = display + chalk.inverse(' ');
743+
}
744+
}
605745
}
606746
}
607-
}
608-
return (
609-
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
610-
);
611-
})
747+
748+
const showCursorBeforeGhost =
749+
focus &&
750+
visualIdxInRenderedSet === cursorVisualRow &&
751+
cursorVisualColAbsolute ===
752+
// eslint-disable-next-line no-control-regex
753+
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
754+
currentLineGhost;
755+
756+
const actualDisplayWidth = stringWidth(display);
757+
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
758+
const totalContentWidth =
759+
actualDisplayWidth + cursorWidth + ghostWidth;
760+
const trailingPadding = Math.max(
761+
0,
762+
inputWidth - totalContentWidth,
763+
);
764+
765+
return (
766+
<Text key={`line-${visualIdxInRenderedSet}`}>
767+
{display}
768+
{showCursorBeforeGhost && chalk.inverse(' ')}
769+
{currentLineGhost && (
770+
<Text color={theme.text.secondary}>
771+
{currentLineGhost}
772+
</Text>
773+
)}
774+
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
775+
</Text>
776+
);
777+
})
778+
.concat(
779+
additionalLines.map((ghostLine, index) => {
780+
const padding = Math.max(
781+
0,
782+
inputWidth - stringWidth(ghostLine),
783+
);
784+
return (
785+
<Text
786+
key={`ghost-line-${index}`}
787+
color={theme.text.secondary}
788+
>
789+
{ghostLine}
790+
{' '.repeat(padding)}
791+
</Text>
792+
);
793+
}),
794+
)
612795
)}
613796
</Box>
614797
</Box>

packages/cli/src/ui/hooks/useCommandCompletion.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ const setupMocks = ({
8484

8585
describe('useCommandCompletion', () => {
8686
const mockCommandContext = {} as CommandContext;
87-
const mockConfig = {} as Config;
87+
const mockConfig = {
88+
getEnablePromptCompletion: () => false,
89+
} as Config;
8890
const testDirs: string[] = [];
8991
const testRootDir = '/';
9092

@@ -511,7 +513,7 @@ describe('useCommandCompletion', () => {
511513
});
512514

513515
expect(result.current.textBuffer.text).toBe(
514-
'@src/file1.txt is a good file',
516+
'@src/file1.txt is a good file',
515517
);
516518
});
517519
});

0 commit comments

Comments
 (0)