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;
}