diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index f6cd545438..c494b6d57a 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -128,9 +128,10 @@ available combinations. - `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. - `!` on an empty prompt: Enter or exit shell mode. -- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press - `Esc`, `Backspace`, or any printable key to close it. Press `?` again to close - the panel and insert a `?` into the prompt. +- `?` on an empty prompt: First press opens the shortcuts panel, second press + closes it without inserting text, and third press inserts a literal `?`. + 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 mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 9b4444a6e9..df03f55791 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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 ( + + { + onSetShortcutsHelpVisible(visible); + setShortcutsHelpVisible(visible); + }, + }} + > + + + + ); + }; + + it('should use tri-state ? behavior on an empty prompt', async () => { + const setShortcutsHelpVisible = vi.fn(); + const { stdin, unmount } = renderWithProviders( + , + { + 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( + , + { + 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', diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 49c609ec9b..898ae180bf 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ number | null >(null); const pasteTimeoutRef = useRef(null); + // Empty prompt `?` follows a 3-step cycle: + // open shortcuts panel -> close panel -> insert literal `?`. + const questionToggleStateRef = useRef('idle'); const innerBoxRef = useRef(null); const [reverseSearchActive, setReverseSearchActive] = useState(false); @@ -362,6 +370,7 @@ export const InputPrompt: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ // 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; }