Add Esc-Esc to clear prompt when it's not empty (#17131)

This commit is contained in:
Adib234
2026-01-20 19:32:26 -05:00
committed by GitHub
parent 3b626e7c61
commit e1fd5be429
7 changed files with 82 additions and 12 deletions

View File

@@ -117,7 +117,8 @@ available combinations.
- `!` on an empty prompt: Enter or exit shell mode.
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
mode.
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
otherwise browse and rewind previous interactions.
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to

View File

@@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
buffer: '',
buffer: { text: '' },
inputWidth: 80,
suggestionsWidth: 40,
userMessages: [],
@@ -389,6 +389,7 @@ describe('Composer', () => {
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
});
const { lastFrame } = renderComposer(uiState);

View File

@@ -1893,10 +1893,35 @@ describe('InputPrompt', () => {
unmount();
});
it('should submit /rewind on double ESC', async () => {
it('should submit /rewind on double ESC when buffer is empty', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
history: [{ id: 1, type: 'user', text: 'test' }],
},
},
);
await act(async () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
});
unmount();
});
it('should clear the buffer on esc esc if it has text', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
vi.mocked(props.buffer.setText).mockClear();
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
@@ -1906,7 +1931,8 @@ describe('InputPrompt', () => {
stdin.write('\x1B\x1B');
vi.advanceTimersByTime(100);
expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
});
unmount();
});

View File

@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { mainAreaWidth, activePtyId } = useUIState();
const { mainAreaWidth, activePtyId, history } = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Handle double ESC for rewind
// Handle double ESC
if (escPressCount.current === 0) {
escPressCount.current = 1;
setShowEscapePrompt(true);
@@ -506,9 +506,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetEscapeState();
}, 500);
} else {
// Second ESC triggers rewind
// Second ESC
resetEscapeState();
onSubmit('/rewind');
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
} else {
if (history.length > 0) {
onSubmit('/rewind');
}
}
}
return;
}
@@ -880,6 +887,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
history,
],
);

View File

@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -23,8 +24,13 @@ vi.mock('./HookStatusDisplay.js', () => ({
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
}));
// Use a type that allows partial buffer for mocking purposes
type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
buffer?: Partial<TextBuffer>;
};
// Create mock context providers
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
@@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
}) as UIState;
@@ -147,9 +155,22 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt', () => {
it('renders Escape prompt when buffer is empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: '' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: 'some text' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },

View File

@@ -45,7 +45,18 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
}
if (uiState.showEscapePrompt) {
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (uiState.queueErrorMessage) {

View File

@@ -10,7 +10,9 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;