mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
Add Esc-Esc to clear prompt when it's not empty (#17131)
This commit is contained in:
@@ -117,7 +117,8 @@ available combinations.
|
|||||||
- `!` on an empty prompt: Enter or exit shell mode.
|
- `!` on an empty prompt: Enter or exit shell mode.
|
||||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||||
mode.
|
mode.
|
||||||
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
|
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||||
|
otherwise browse and rewind previous interactions.
|
||||||
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
|
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
|
||||||
single-line input, navigate backward or forward through prompt history.
|
single-line input, navigate backward or forward through prompt history.
|
||||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
showErrorDetails: false,
|
showErrorDetails: false,
|
||||||
constrainHeight: false,
|
constrainHeight: false,
|
||||||
isInputActive: true,
|
isInputActive: true,
|
||||||
buffer: '',
|
buffer: { text: '' },
|
||||||
inputWidth: 80,
|
inputWidth: 80,
|
||||||
suggestionsWidth: 40,
|
suggestionsWidth: 40,
|
||||||
userMessages: [],
|
userMessages: [],
|
||||||
@@ -389,6 +389,7 @@ describe('Composer', () => {
|
|||||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
showEscapePrompt: true,
|
showEscapePrompt: true,
|
||||||
|
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame } = renderComposer(uiState);
|
const { lastFrame } = renderComposer(uiState);
|
||||||
|
|||||||
@@ -1893,10 +1893,35 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should submit /rewind on double ESC', async () => {
|
it('should submit /rewind on double ESC when buffer is empty', async () => {
|
||||||
|
const onEscapePromptChange = vi.fn();
|
||||||
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
|
props.buffer.setText('');
|
||||||
|
vi.mocked(props.buffer.setText).mockClear();
|
||||||
|
|
||||||
|
const { stdin, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
{
|
||||||
|
uiState: {
|
||||||
|
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\x1B\x1B');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the buffer on esc esc if it has text', async () => {
|
||||||
const onEscapePromptChange = vi.fn();
|
const onEscapePromptChange = vi.fn();
|
||||||
props.onEscapePromptChange = onEscapePromptChange;
|
props.onEscapePromptChange = onEscapePromptChange;
|
||||||
props.buffer.setText('some text');
|
props.buffer.setText('some text');
|
||||||
|
vi.mocked(props.buffer.setText).mockClear();
|
||||||
|
|
||||||
const { stdin, unmount } = renderWithProviders(
|
const { stdin, unmount } = renderWithProviders(
|
||||||
<InputPrompt {...props} />,
|
<InputPrompt {...props} />,
|
||||||
@@ -1906,7 +1931,8 @@ describe('InputPrompt', () => {
|
|||||||
stdin.write('\x1B\x1B');
|
stdin.write('\x1B\x1B');
|
||||||
vi.advanceTimersByTime(100);
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
|
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
|
||||||
});
|
});
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
const kittyProtocol = useKittyKeyboardProtocol();
|
const kittyProtocol = useKittyKeyboardProtocol();
|
||||||
const isShellFocused = useShellFocusState();
|
const isShellFocused = useShellFocusState();
|
||||||
const { setEmbeddedShellFocused } = useUIActions();
|
const { setEmbeddedShellFocused } = useUIActions();
|
||||||
const { mainAreaWidth, activePtyId } = useUIState();
|
const { mainAreaWidth, activePtyId, history } = useUIState();
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
const escPressCount = useRef(0);
|
const escPressCount = useRef(0);
|
||||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle double ESC for rewind
|
// Handle double ESC
|
||||||
if (escPressCount.current === 0) {
|
if (escPressCount.current === 0) {
|
||||||
escPressCount.current = 1;
|
escPressCount.current = 1;
|
||||||
setShowEscapePrompt(true);
|
setShowEscapePrompt(true);
|
||||||
@@ -506,10 +506,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
// Second ESC triggers rewind
|
// Second ESC
|
||||||
resetEscapeState();
|
resetEscapeState();
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
resetCompletionState();
|
||||||
|
} else {
|
||||||
|
if (history.length > 0) {
|
||||||
onSubmit('/rewind');
|
onSubmit('/rewind');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,6 +887,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
|
history,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
|
|||||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
|
|
||||||
// Mock child components to simplify testing
|
// Mock child components to simplify testing
|
||||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||||
@@ -23,8 +24,13 @@ vi.mock('./HookStatusDisplay.js', () => ({
|
|||||||
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
|
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Use a type that allows partial buffer for mocking purposes
|
||||||
|
type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
|
||||||
|
buffer?: Partial<TextBuffer>;
|
||||||
|
};
|
||||||
|
|
||||||
// Create mock context providers
|
// Create mock context providers
|
||||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||||
({
|
({
|
||||||
ctrlCPressedOnce: false,
|
ctrlCPressedOnce: false,
|
||||||
warningMessage: null,
|
warningMessage: null,
|
||||||
@@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||||||
ideContextState: null,
|
ideContextState: null,
|
||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
contextFileNames: [],
|
contextFileNames: [],
|
||||||
|
buffer: { text: '' },
|
||||||
|
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as UIState;
|
}) as UIState;
|
||||||
|
|
||||||
@@ -147,9 +155,22 @@ describe('StatusDisplay', () => {
|
|||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Escape prompt', () => {
|
it('renders Escape prompt when buffer is empty', () => {
|
||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
showEscapePrompt: true,
|
showEscapePrompt: true,
|
||||||
|
buffer: { text: '' },
|
||||||
|
});
|
||||||
|
const { lastFrame } = renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
uiState,
|
||||||
|
);
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Escape prompt when buffer is NOT empty', () => {
|
||||||
|
const uiState = createMockUIState({
|
||||||
|
showEscapePrompt: true,
|
||||||
|
buffer: { text: 'some text' },
|
||||||
});
|
});
|
||||||
const { lastFrame } = renderStatusDisplay(
|
const { lastFrame } = renderStatusDisplay(
|
||||||
{ hideContextSummary: false },
|
{ hideContextSummary: false },
|
||||||
|
|||||||
@@ -45,7 +45,18 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.showEscapePrompt) {
|
if (uiState.showEscapePrompt) {
|
||||||
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
|
const isPromptEmpty = uiState.buffer.text.length === 0;
|
||||||
|
const hasHistory = uiState.history.length > 0;
|
||||||
|
|
||||||
|
if (isPromptEmpty && !hasHistory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.queueErrorMessage) {
|
if (uiState.queueErrorMessage) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
|
|||||||
|
|
||||||
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
|
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
|
||||||
|
|
||||||
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
|
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
|
||||||
|
|
||||||
|
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
|
||||||
|
|
||||||
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
|
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user