Skip to content

Commit dd16d9a

Browse files
Adib234Thomas-Shephard
authored andcommitted
Add Esc-Esc to clear prompt when it's not empty (google-gemini#17131)
1 parent b8db5ba commit dd16d9a

7 files changed

Lines changed: 82 additions & 12 deletions

File tree

docs/cli/keyboard-shortcuts.md

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

packages/cli/src/ui/components/Composer.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
100100
showErrorDetails: false,
101101
constrainHeight: false,
102102
isInputActive: true,
103-
buffer: '',
103+
buffer: { text: '' },
104104
inputWidth: 80,
105105
suggestionsWidth: 40,
106106
userMessages: [],
@@ -389,6 +389,7 @@ describe('Composer', () => {
389389
it('shows escape prompt when showEscapePrompt is true', () => {
390390
const uiState = createMockUIState({
391391
showEscapePrompt: true,
392+
history: [{ id: 1, type: 'user', text: 'test' }],
392393
});
393394

394395
const { lastFrame } = renderComposer(uiState);

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,13 +1893,19 @@ describe('InputPrompt', () => {
18931893
unmount();
18941894
});
18951895

1896-
it('should submit /rewind on double ESC', async () => {
1896+
it('should submit /rewind on double ESC when buffer is empty', async () => {
18971897
const onEscapePromptChange = vi.fn();
18981898
props.onEscapePromptChange = onEscapePromptChange;
1899-
props.buffer.setText('some text');
1899+
props.buffer.setText('');
1900+
vi.mocked(props.buffer.setText).mockClear();
19001901

19011902
const { stdin, unmount } = renderWithProviders(
19021903
<InputPrompt {...props} />,
1904+
{
1905+
uiState: {
1906+
history: [{ id: 1, type: 'user', text: 'test' }],
1907+
},
1908+
},
19031909
);
19041910

19051911
await act(async () => {
@@ -1911,6 +1917,26 @@ describe('InputPrompt', () => {
19111917
unmount();
19121918
});
19131919

1920+
it('should clear the buffer on esc esc if it has text', async () => {
1921+
const onEscapePromptChange = vi.fn();
1922+
props.onEscapePromptChange = onEscapePromptChange;
1923+
props.buffer.setText('some text');
1924+
vi.mocked(props.buffer.setText).mockClear();
1925+
1926+
const { stdin, unmount } = renderWithProviders(
1927+
<InputPrompt {...props} />,
1928+
);
1929+
1930+
await act(async () => {
1931+
stdin.write('\x1B\x1B');
1932+
vi.advanceTimersByTime(100);
1933+
1934+
expect(props.buffer.setText).toHaveBeenCalledWith('');
1935+
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
1936+
});
1937+
unmount();
1938+
});
1939+
19141940
it('should reset escape state on any non-ESC key', async () => {
19151941
const onEscapePromptChange = vi.fn();
19161942
props.onEscapePromptChange = onEscapePromptChange;

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
138138
const kittyProtocol = useKittyKeyboardProtocol();
139139
const isShellFocused = useShellFocusState();
140140
const { setEmbeddedShellFocused } = useUIActions();
141-
const { mainAreaWidth, activePtyId } = useUIState();
141+
const { mainAreaWidth, activePtyId, history } = useUIState();
142142
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
143143
const escPressCount = useRef(0);
144144
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
495495
return;
496496
}
497497

498-
// Handle double ESC for rewind
498+
// Handle double ESC
499499
if (escPressCount.current === 0) {
500500
escPressCount.current = 1;
501501
setShowEscapePrompt(true);
@@ -506,9 +506,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
506506
resetEscapeState();
507507
}, 500);
508508
} else {
509-
// Second ESC triggers rewind
509+
// Second ESC
510510
resetEscapeState();
511-
onSubmit('/rewind');
511+
if (buffer.text.length > 0) {
512+
buffer.setText('');
513+
resetCompletionState();
514+
} else {
515+
if (history.length > 0) {
516+
onSubmit('/rewind');
517+
}
518+
}
512519
}
513520
return;
514521
}
@@ -880,6 +887,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
880887
onSubmit,
881888
activePtyId,
882889
setEmbeddedShellFocused,
890+
history,
883891
],
884892
);
885893

packages/cli/src/ui/components/StatusDisplay.test.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js';
1111
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
1212
import { ConfigContext } from '../contexts/ConfigContext.js';
1313
import { SettingsContext } from '../contexts/SettingsContext.js';
14+
import type { TextBuffer } from './shared/text-buffer.js';
1415

1516
// Mock child components to simplify testing
1617
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -23,8 +24,13 @@ vi.mock('./HookStatusDisplay.js', () => ({
2324
HookStatusDisplay: () => <Text>Mock Hook Status Display</Text>,
2425
}));
2526

27+
// Use a type that allows partial buffer for mocking purposes
28+
type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
29+
buffer?: Partial<TextBuffer>;
30+
};
31+
2632
// Create mock context providers
27-
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
33+
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
2834
({
2935
ctrlCPressedOnce: false,
3036
warningMessage: null,
@@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
3541
ideContextState: null,
3642
geminiMdFileCount: 0,
3743
contextFileNames: [],
44+
buffer: { text: '' },
45+
history: [{ id: 1, type: 'user', text: 'test' }],
3846
...overrides,
3947
}) as UIState;
4048

@@ -147,9 +155,22 @@ describe('StatusDisplay', () => {
147155
expect(lastFrame()).toMatchSnapshot();
148156
});
149157

150-
it('renders Escape prompt', () => {
158+
it('renders Escape prompt when buffer is empty', () => {
159+
const uiState = createMockUIState({
160+
showEscapePrompt: true,
161+
buffer: { text: '' },
162+
});
163+
const { lastFrame } = renderStatusDisplay(
164+
{ hideContextSummary: false },
165+
uiState,
166+
);
167+
expect(lastFrame()).toMatchSnapshot();
168+
});
169+
170+
it('renders Escape prompt when buffer is NOT empty', () => {
151171
const uiState = createMockUIState({
152172
showEscapePrompt: true,
173+
buffer: { text: 'some text' },
153174
});
154175
const { lastFrame } = renderStatusDisplay(
155176
{ hideContextSummary: false },

packages/cli/src/ui/components/StatusDisplay.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,18 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
4545
}
4646

4747
if (uiState.showEscapePrompt) {
48-
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
48+
const isPromptEmpty = uiState.buffer.text.length === 0;
49+
const hasHistory = uiState.history.length > 0;
50+
51+
if (isPromptEmpty && !hasHistory) {
52+
return null;
53+
}
54+
55+
return (
56+
<Text color={theme.text.secondary}>
57+
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
58+
</Text>
59+
);
4960
}
5061

5162
if (uiState.queueErrorMessage) {

packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C
1010

1111
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
1212

13-
exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`;
13+
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
14+
15+
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
1416

1517
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
1618

0 commit comments

Comments
 (0)