mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-13 14:50:39 -07:00
fix(cli): Polish shell autocomplete rendering to be a little more shell native feeling. (#20931)
This commit is contained in:
@@ -46,6 +46,7 @@ vi.mock('./useShellCompletion', () => ({
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
activeStart: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -57,7 +58,12 @@ const setupMocks = ({
|
||||
isLoading = false,
|
||||
isPerfectMatch = false,
|
||||
slashCompletionRange = { completionStart: 0, completionEnd: 0 },
|
||||
shellCompletionRange = { completionStart: 0, completionEnd: 0, query: '' },
|
||||
shellCompletionRange = {
|
||||
completionStart: 0,
|
||||
completionEnd: 0,
|
||||
query: '',
|
||||
activeStart: 0,
|
||||
},
|
||||
}: {
|
||||
atSuggestions?: Suggestion[];
|
||||
slashSuggestions?: Suggestion[];
|
||||
@@ -69,6 +75,7 @@ const setupMocks = ({
|
||||
completionStart: number;
|
||||
completionEnd: number;
|
||||
query: string;
|
||||
activeStart?: number;
|
||||
};
|
||||
}) => {
|
||||
// Mock for @-completions
|
||||
@@ -116,7 +123,10 @@ const setupMocks = ({
|
||||
setSuggestions(shellSuggestions);
|
||||
}
|
||||
}, [enabled, setSuggestions, setIsLoadingSuggestions]);
|
||||
return shellCompletionRange;
|
||||
return {
|
||||
...shellCompletionRange,
|
||||
activeStart: shellCompletionRange.activeStart ?? 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -139,38 +149,57 @@ describe('useCommandCompletion', () => {
|
||||
});
|
||||
}
|
||||
|
||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
||||
};
|
||||
|
||||
function TestComponent({
|
||||
initialText,
|
||||
cursorOffset,
|
||||
shellModeActive,
|
||||
active,
|
||||
}: {
|
||||
initialText: string;
|
||||
cursorOffset?: number;
|
||||
shellModeActive: boolean;
|
||||
active: boolean;
|
||||
}) {
|
||||
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
|
||||
const completion = useCommandCompletion({
|
||||
buffer: textBuffer,
|
||||
cwd: testRootDir,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
reverseSearchActive: false,
|
||||
shellModeActive,
|
||||
config: mockConfig,
|
||||
active,
|
||||
});
|
||||
hookResult = { ...completion, textBuffer };
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderCommandCompletionHook = (
|
||||
initialText: string,
|
||||
cursorOffset?: number,
|
||||
shellModeActive = false,
|
||||
active = true,
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useCommandCompletion> & {
|
||||
textBuffer: ReturnType<typeof useTextBuffer>;
|
||||
};
|
||||
|
||||
function TestComponent() {
|
||||
const textBuffer = useTextBufferForTest(initialText, cursorOffset);
|
||||
const completion = useCommandCompletion({
|
||||
buffer: textBuffer,
|
||||
cwd: testRootDir,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
reverseSearchActive: false,
|
||||
shellModeActive,
|
||||
config: mockConfig,
|
||||
active,
|
||||
});
|
||||
hookResult = { ...completion, textBuffer };
|
||||
return null;
|
||||
}
|
||||
renderWithProviders(<TestComponent />);
|
||||
const renderResult = renderWithProviders(
|
||||
<TestComponent
|
||||
initialText={initialText}
|
||||
cursorOffset={cursorOffset}
|
||||
shellModeActive={shellModeActive}
|
||||
active={active}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
...renderResult,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -524,6 +553,129 @@ describe('useCommandCompletion', () => {
|
||||
|
||||
expect(result.current.textBuffer.text).toBe('@src\\components\\');
|
||||
});
|
||||
|
||||
it('should show ghost text for a single shell completion', async () => {
|
||||
const text = 'l';
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 1,
|
||||
query: 'l',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
text.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Should show "ls " as ghost text (including trailing space)
|
||||
expect(result.current.promptCompletion.text).toBe('ls ');
|
||||
});
|
||||
|
||||
it('should not show ghost text if there are multiple completions', async () => {
|
||||
const text = 'l';
|
||||
setupMocks({
|
||||
shellSuggestions: [
|
||||
{ label: 'ls', value: 'ls' },
|
||||
{ label: 'ln', value: 'ln' },
|
||||
],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 1,
|
||||
query: 'l',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
text.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
|
||||
it('should not show ghost text if the typed text extends past the completion', async () => {
|
||||
// "ls " is already typed.
|
||||
const text = 'ls ';
|
||||
const cursorOffset = text.length;
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
text,
|
||||
cursorOffset,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
|
||||
it('should clear ghost text after user types a space when exact match ghost text was showing', async () => {
|
||||
const textWithoutSpace = 'ls';
|
||||
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }],
|
||||
shellCompletionRange: {
|
||||
completionStart: 0,
|
||||
completionEnd: 2,
|
||||
query: 'ls',
|
||||
activeStart: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderCommandCompletionHook(
|
||||
textWithoutSpace,
|
||||
textWithoutSpace.length,
|
||||
true, // shellModeActive
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Initially no ghost text because "ls" perfectly matches "ls"
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
|
||||
// Now simulate typing a space.
|
||||
// In the real app, shellCompletionRange.completionStart would change immediately to 3,
|
||||
// but suggestions (and activeStart) would still be from the previous token for a few ms.
|
||||
setupMocks({
|
||||
shellSuggestions: [{ label: 'ls', value: 'ls' }], // Stale suggestions
|
||||
shellCompletionRange: {
|
||||
completionStart: 3, // New token position
|
||||
completionEnd: 3,
|
||||
query: '',
|
||||
activeStart: 0, // Stale active start
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.textBuffer.setText('ls ', 'end');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
// Should STILL be empty because completionStart (3) !== activeStart (0)
|
||||
expect(result.current.promptCompletion.text).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt completion filtering', () => {
|
||||
|
||||
Reference in New Issue
Block a user