mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(cli): implement tri-state '?' shortcuts toggle (#18547)
This commit is contained in:
@@ -128,9 +128,10 @@ available combinations.
|
|||||||
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
|
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
|
||||||
terminal isn't configured to send Meta with Option.
|
terminal isn't configured to send Meta with Option.
|
||||||
- `!` on an empty prompt: Enter or exit shell mode.
|
- `!` on an empty prompt: Enter or exit shell mode.
|
||||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
- `?` on an empty prompt: First press opens the shortcuts panel, second press
|
||||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
closes it without inserting text, and third press inserts a literal `?`.
|
||||||
the panel and insert a `?` into the prompt.
|
Pressing `Esc` or `Backspace` closes the panel. Pressing any other printable
|
||||||
|
key closes the panel and inserts that key.
|
||||||
- `\` (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: Clear the input prompt if it is not empty,
|
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { createMockSettings } from '../../test-utils/settings.js';
|
import { createMockSettings } from '../../test-utils/settings.js';
|
||||||
import { waitFor } from '../../test-utils/async.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 type { InputPromptProps } from './InputPrompt.js';
|
||||||
import { InputPrompt } from './InputPrompt.js';
|
import { InputPrompt } from './InputPrompt.js';
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
@@ -41,7 +41,8 @@ import stripAnsi from 'strip-ansi';
|
|||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.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 { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||||
import { cpLen } from '../utils/textUtils.js';
|
import { cpLen } from '../utils/textUtils.js';
|
||||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||||
@@ -4030,6 +4031,119 @@ describe('InputPrompt', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('shortcuts help visibility', () => {
|
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([
|
it.each([
|
||||||
{
|
{
|
||||||
name: 'terminal paste event occurs',
|
name: 'terminal paste event occurs',
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export interface InputPromptProps {
|
|||||||
setBannerVisible: (visible: boolean) => void;
|
setBannerVisible: (visible: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuestionToggleState =
|
||||||
|
| 'idle'
|
||||||
|
| 'openedByQuestion'
|
||||||
|
| 'dismissedByQuestionToggle';
|
||||||
|
|
||||||
// The input content, input container, and input suggestions list may have different widths
|
// The input content, input container, and input suggestions list may have different widths
|
||||||
export const calculatePromptWidths = (mainContentWidth: number) => {
|
export const calculatePromptWidths = (mainContentWidth: number) => {
|
||||||
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
|
||||||
@@ -169,6 +174,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | 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 innerBoxRef = useRef<DOMElement>(null);
|
||||||
|
|
||||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||||
@@ -362,6 +370,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (shortcutsHelpVisible) {
|
if (shortcutsHelpVisible) {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
}
|
}
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
try {
|
try {
|
||||||
if (await clipboardHasImage()) {
|
if (await clipboardHasImage()) {
|
||||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||||
@@ -546,11 +555,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return false;
|
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
|
// Handle escape to close shortcuts panel first, before letting it bubble
|
||||||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||||||
// and pressing again cancels the operation.
|
// and pressing again cancels the operation.
|
||||||
if (shortcutsHelpVisible && key.name === 'escape') {
|
if (shortcutsHelpVisible && key.name === 'escape') {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,6 +618,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (shortcutsHelpVisible) {
|
if (shortcutsHelpVisible) {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
}
|
}
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
// Record paste time to prevent accidental auto-submission
|
// Record paste time to prevent accidental auto-submission
|
||||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||||
setRecentUnsafePasteTime(Date.now());
|
setRecentUnsafePasteTime(Date.now());
|
||||||
@@ -597,6 +650,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (shortcutsHelpVisible) {
|
if (shortcutsHelpVisible) {
|
||||||
if (key.sequence === '?' && key.insertable) {
|
if (key.sequence === '?' && key.insertable) {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
buffer.handleInput(key);
|
buffer.handleInput(key);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -604,23 +658,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
// potentially cancelling an operation
|
// potentially cancelling an operation
|
||||||
if (key.name === 'backspace' || key.sequence === '\b') {
|
if (key.name === 'backspace' || key.sequence === '\b') {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (key.insertable) {
|
if (key.insertable) {
|
||||||
setShortcutsHelpVisible(false);
|
setShortcutsHelpVisible(false);
|
||||||
|
questionToggleStateRef.current = 'idle';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
key.sequence === '?' &&
|
|
||||||
key.insertable &&
|
|
||||||
!shortcutsHelpVisible &&
|
|
||||||
buffer.text.length === 0
|
|
||||||
) {
|
|
||||||
setShortcutsHelpVisible(true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vimHandleInput && vimHandleInput(key)) {
|
if (vimHandleInput && vimHandleInput(key)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user