mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 15:04:16 -07:00
fix(cli): allow perfect match @-path completions to submit on Enter (#19562)
This commit is contained in:
@@ -4,7 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render as inkRenderDirect, type Instance as InkInstance } from 'ink';
|
import {
|
||||||
|
render as inkRenderDirect,
|
||||||
|
type Instance as InkInstance,
|
||||||
|
type RenderOptions,
|
||||||
|
} from 'ink';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Box } from 'ink';
|
import { Box } from 'ink';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
@@ -68,6 +72,25 @@ type TerminalState = {
|
|||||||
rows: number;
|
rows: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RenderMetrics = Parameters<NonNullable<RenderOptions['onRender']>>[0];
|
||||||
|
|
||||||
|
interface InkRenderMetrics extends RenderMetrics {
|
||||||
|
output: string;
|
||||||
|
staticOutput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInkRenderMetrics(
|
||||||
|
metrics: RenderMetrics,
|
||||||
|
): metrics is InkRenderMetrics {
|
||||||
|
const m = metrics as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof m === 'object' &&
|
||||||
|
m !== null &&
|
||||||
|
'output' in m &&
|
||||||
|
typeof m['output'] === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class XtermStdout extends EventEmitter {
|
class XtermStdout extends EventEmitter {
|
||||||
private state: TerminalState;
|
private state: TerminalState;
|
||||||
private pendingWrites = 0;
|
private pendingWrites = 0;
|
||||||
@@ -357,8 +380,10 @@ export const render = (
|
|||||||
debug: false,
|
debug: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
patchConsole: false,
|
patchConsole: false,
|
||||||
onRender: (metrics: { output: string; staticOutput?: string }) => {
|
onRender: (metrics: RenderMetrics) => {
|
||||||
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
if (isInkRenderMetrics(metrics)) {
|
||||||
|
stdout.onRender(metrics.staticOutput ?? '', metrics.output);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1065,7 +1065,7 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT submit on Enter when an @-path is a perfect match', async () => {
|
it('should submit on Enter when an @-path is a perfect match', async () => {
|
||||||
mockedUseCommandCompletion.mockReturnValue({
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
...mockCommandCompletion,
|
...mockCommandCompletion,
|
||||||
showSuggestions: true,
|
showSuggestions: true,
|
||||||
@@ -1085,13 +1085,38 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should handle autocomplete but NOT submit
|
// Should submit directly
|
||||||
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
expect(props.onSubmit).toHaveBeenCalledWith('@file.txt');
|
||||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => {
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'file.txt', value: 'file.txt' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
isPerfectMatch: true,
|
||||||
|
completionMode: CompletionMode.AT,
|
||||||
|
});
|
||||||
|
props.buffer.text = '@file.txt';
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||||
|
uiActions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Simulate Shift+Enter using CSI u sequence
|
||||||
|
stdin.write('\x1b[13;2u');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT submit, should call newline instead
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
expect(props.buffer.newline).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('should auto-execute commands with autoExecute: true on Enter', async () => {
|
it('should auto-execute commands with autoExecute: true on Enter', async () => {
|
||||||
const aboutCommand: SlashCommand = {
|
const aboutCommand: SlashCommand = {
|
||||||
name: 'about',
|
name: 'about',
|
||||||
@@ -2285,6 +2310,36 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => {
|
||||||
|
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||||
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
|
...mockCommandCompletion,
|
||||||
|
isPerfectMatch: true,
|
||||||
|
completionMode: CompletionMode.AT,
|
||||||
|
});
|
||||||
|
props.buffer.text = '@file.txt';
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate an unsafe paste of a perfect match
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write(`\x1b[200~@file.txt\x1b[201~`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate an Enter key press immediately after paste
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\r');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that onSubmit was NOT called due to recent paste protection
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
// It should call newline() instead
|
||||||
|
expect(props.buffer.newline).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow submission after unsafe paste protection timeout', async () => {
|
it('should allow submission after unsafe paste protection timeout', async () => {
|
||||||
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
// isTerminalPasteTrusted will be false due to beforeEach setup.
|
||||||
props.buffer.text = 'pasted text';
|
props.buffer.text = 'pasted text';
|
||||||
|
|||||||
@@ -890,14 +890,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
// We prioritize execution unless the user is explicitly selecting a different suggestion.
|
||||||
if (
|
if (
|
||||||
completion.isPerfectMatch &&
|
completion.isPerfectMatch &&
|
||||||
completion.completionMode !== CompletionMode.AT &&
|
keyMatchers[Command.SUBMIT](key) &&
|
||||||
keyMatchers[Command.RETURN](key) &&
|
recentUnsafePasteTime === null &&
|
||||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||||
) {
|
) {
|
||||||
handleSubmit(buffer.text);
|
handleSubmit(buffer.text);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newline insertion
|
||||||
|
if (keyMatchers[Command.NEWLINE](key)) {
|
||||||
|
buffer.newline();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
if (completion.suggestions.length > 1) {
|
if (completion.suggestions.length > 1) {
|
||||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||||
@@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newline insertion
|
|
||||||
if (keyMatchers[Command.NEWLINE](key)) {
|
|
||||||
buffer.newline();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+A (Home) / Ctrl+E (End)
|
// Ctrl+A (Home) / Ctrl+E (End)
|
||||||
if (keyMatchers[Command.HOME](key)) {
|
if (keyMatchers[Command.HOME](key)) {
|
||||||
buffer.move('home');
|
buffer.move('home');
|
||||||
|
|||||||
Reference in New Issue
Block a user