feat(cli): implement tri-state '?' shortcuts toggle (#18547)

This commit is contained in:
Dmitry Lyalin
2026-02-08 19:23:42 -05:00
parent 29a6aecffc
commit 5857bfdf60
3 changed files with 176 additions and 15 deletions
@@ -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',
+56 -10
View File
@@ -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;
}