mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(cli): implement tri-state '?' shortcuts toggle (#18547)
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act, useState } from 'react';
|
||||
import { act, useContext, useState } from 'react';
|
||||
import type { InputPromptProps } from './InputPrompt.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
@@ -41,7 +41,8 @@ import stripAnsi from 'strip-ansi';
|
||||
import chalk from 'chalk';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { UIActionsContext } from '../contexts/UIActionsContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
@@ -4030,6 +4031,119 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
const StatefulShortcutsInputPrompt = ({
|
||||
onSetShortcutsHelpVisible,
|
||||
}: {
|
||||
onSetShortcutsHelpVisible: (visible: boolean) => void;
|
||||
}) => {
|
||||
const parentUiState = useContext(UIStateContext);
|
||||
const parentUiActions = useContext(UIActionsContext);
|
||||
|
||||
if (!parentUiState || !parentUiActions) {
|
||||
throw new Error(
|
||||
'StatefulShortcutsInputPrompt must be rendered in context',
|
||||
);
|
||||
}
|
||||
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(
|
||||
parentUiState.shortcutsHelpVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider
|
||||
value={{ ...parentUiState, shortcutsHelpVisible }}
|
||||
>
|
||||
<UIActionsContext.Provider
|
||||
value={{
|
||||
...parentUiActions,
|
||||
setShortcutsHelpVisible: (visible: boolean) => {
|
||||
onSetShortcutsHelpVisible(visible);
|
||||
setShortcutsHelpVisible(visible);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<InputPrompt {...props} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
it('should use tri-state ? behavior on an empty prompt', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<StatefulShortcutsInputPrompt
|
||||
onSetShortcutsHelpVisible={setShortcutsHelpVisible}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { shortcutsHelpVisible: false },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenNthCalledWith(1, true);
|
||||
});
|
||||
expect(props.buffer.handleInput).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
expect(props.buffer.handleInput).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.buffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sequence: '?', insertable: true }),
|
||||
);
|
||||
});
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should close the shortcuts panel and insert other characters normally', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<StatefulShortcutsInputPrompt
|
||||
onSetShortcutsHelpVisible={setShortcutsHelpVisible}
|
||||
/>,
|
||||
{
|
||||
uiActions,
|
||||
uiState: { shortcutsHelpVisible: false },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenNthCalledWith(1, true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
expect(props.buffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sequence: 'a', insertable: true }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
|
||||
@@ -106,6 +106,11 @@ export interface InputPromptProps {
|
||||
setBannerVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
type QuestionToggleState =
|
||||
| 'idle'
|
||||
| 'openedByQuestion'
|
||||
| 'dismissedByQuestionToggle';
|
||||
|
||||
// The input content, input container, and input suggestions list may have different widths
|
||||
export const calculatePromptWidths = (mainContentWidth: number) => {
|
||||
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
||||
@@ -169,6 +174,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
number | null
|
||||
>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Empty prompt `?` follows a 3-step cycle:
|
||||
// open shortcuts panel -> close panel -> insert literal `?`.
|
||||
const questionToggleStateRef = useRef<QuestionToggleState>('idle');
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
@@ -362,6 +370,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
questionToggleStateRef.current = 'idle';
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
@@ -546,11 +555,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const isInsertableQuestion = key.sequence === '?' && key.insertable;
|
||||
const isPromptEmpty = buffer.text.length === 0;
|
||||
|
||||
if (!isPromptEmpty && questionToggleStateRef.current !== 'idle') {
|
||||
questionToggleStateRef.current = 'idle';
|
||||
}
|
||||
|
||||
if (
|
||||
!shortcutsHelpVisible &&
|
||||
questionToggleStateRef.current === 'openedByQuestion'
|
||||
) {
|
||||
questionToggleStateRef.current = 'idle';
|
||||
}
|
||||
|
||||
if (
|
||||
questionToggleStateRef.current === 'dismissedByQuestionToggle' &&
|
||||
!isInsertableQuestion
|
||||
) {
|
||||
questionToggleStateRef.current = 'idle';
|
||||
}
|
||||
|
||||
if (isInsertableQuestion && isPromptEmpty) {
|
||||
if (questionToggleStateRef.current === 'openedByQuestion') {
|
||||
setShortcutsHelpVisible(false);
|
||||
questionToggleStateRef.current = 'dismissedByQuestionToggle';
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
questionToggleStateRef.current === 'dismissedByQuestionToggle' &&
|
||||
!shortcutsHelpVisible
|
||||
) {
|
||||
questionToggleStateRef.current = 'idle';
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
}
|
||||
if (!shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(true);
|
||||
questionToggleStateRef.current = 'openedByQuestion';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape to close shortcuts panel first, before letting it bubble
|
||||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||||
// and pressing again cancels the operation.
|
||||
if (shortcutsHelpVisible && key.name === 'escape') {
|
||||
setShortcutsHelpVisible(false);
|
||||
questionToggleStateRef.current = 'idle';
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -566,6 +618,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
questionToggleStateRef.current = 'idle';
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
setRecentUnsafePasteTime(Date.now());
|
||||
@@ -597,6 +650,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (shortcutsHelpVisible) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
questionToggleStateRef.current = 'idle';
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
}
|
||||
@@ -604,23 +658,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// potentially cancelling an operation
|
||||
if (key.name === 'backspace' || key.sequence === '\b') {
|
||||
setShortcutsHelpVisible(false);
|
||||
questionToggleStateRef.current = 'idle';
|
||||
return true;
|
||||
}
|
||||
if (key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
questionToggleStateRef.current = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user