mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(ui): prevent escape key from cancelling requests in shell mode (#21245)
This commit is contained in:
@@ -61,7 +61,7 @@ import type { UIState } from '../contexts/UIStateContext.js';
|
|||||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||||
import { cpLen } from '../utils/textUtils.js';
|
import { cpLen } from '../utils/textUtils.js';
|
||||||
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
||||||
import type { Key } from '../hooks/useKeypress.js';
|
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||||
import {
|
import {
|
||||||
appEvents,
|
appEvents,
|
||||||
AppEvent,
|
AppEvent,
|
||||||
@@ -163,6 +163,18 @@ describe('InputPrompt', () => {
|
|||||||
let mockBuffer: TextBuffer;
|
let mockBuffer: TextBuffer;
|
||||||
let mockCommandContext: CommandContext;
|
let mockCommandContext: CommandContext;
|
||||||
|
|
||||||
|
const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => {
|
||||||
|
useKeypress(
|
||||||
|
(key) => {
|
||||||
|
if (key.name !== 'escape') return false;
|
||||||
|
onEscape();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ isActive: true, priority: false },
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||||
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
|
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
|
||||||
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
||||||
@@ -2770,6 +2782,54 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => {
|
||||||
|
props.shellModeActive = true;
|
||||||
|
props.streamingState = StreamingState.Responding;
|
||||||
|
const onGlobalEscape = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = await renderWithProviders(
|
||||||
|
<>
|
||||||
|
<GlobalEscapeHandler onEscape={onGlobalEscape} />
|
||||||
|
<InputPrompt {...props} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\x1B');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
expect(onGlobalEscape).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => {
|
||||||
|
props.shellModeActive = false;
|
||||||
|
props.streamingState = StreamingState.Responding;
|
||||||
|
const onGlobalEscape = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = await renderWithProviders(
|
||||||
|
<>
|
||||||
|
<GlobalEscapeHandler onEscape={onGlobalEscape} />
|
||||||
|
<InputPrompt {...props} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
stdin.write('\x1B');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onGlobalEscape).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(props.setShellModeActive).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle ESC when completion suggestions are showing', async () => {
|
it('should handle ESC when completion suggestions are showing', async () => {
|
||||||
mockedUseCommandCompletion.mockReturnValue({
|
mockedUseCommandCompletion.mockReturnValue({
|
||||||
...mockCommandCompletion,
|
...mockCommandCompletion,
|
||||||
|
|||||||
@@ -686,13 +686,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isGenerating =
|
||||||
key.name === 'escape' &&
|
streamingState === StreamingState.Responding ||
|
||||||
(streamingState === StreamingState.Responding ||
|
streamingState === StreamingState.WaitingForConfirmation;
|
||||||
streamingState === StreamingState.WaitingForConfirmation)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPlainTab =
|
const isPlainTab =
|
||||||
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
|
||||||
@@ -877,6 +873,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're generating and no local overlay consumed Escape, let it
|
||||||
|
// propagate to the global cancellation handler.
|
||||||
|
if (isGenerating) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
handleEscPress();
|
handleEscPress();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useKeypressContext,
|
useKeypressContext,
|
||||||
ESC_TIMEOUT,
|
ESC_TIMEOUT,
|
||||||
FAST_RETURN_TIMEOUT,
|
FAST_RETURN_TIMEOUT,
|
||||||
|
KeypressPriority,
|
||||||
type Key,
|
type Key,
|
||||||
} from './KeypressContext.js';
|
} from './KeypressContext.js';
|
||||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
@@ -259,6 +260,48 @@ describe('KeypressContext', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stop propagation when a higher priority handler returns true', async () => {
|
||||||
|
const higherPriorityHandler = vi.fn(() => true);
|
||||||
|
const lowerPriorityHandler = vi.fn();
|
||||||
|
const { result } = await renderHookWithProviders(() =>
|
||||||
|
useKeypressContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.subscribe(higherPriorityHandler, KeypressPriority.High);
|
||||||
|
result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => stdin.write('\x1b[27u'));
|
||||||
|
|
||||||
|
expect(higherPriorityHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'escape' }),
|
||||||
|
);
|
||||||
|
expect(lowerPriorityHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue propagation when a higher priority handler does not consume the event', async () => {
|
||||||
|
const higherPriorityHandler = vi.fn(() => false);
|
||||||
|
const lowerPriorityHandler = vi.fn();
|
||||||
|
const { result } = await renderHookWithProviders(() =>
|
||||||
|
useKeypressContext(),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.subscribe(higherPriorityHandler, KeypressPriority.High);
|
||||||
|
result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => stdin.write('\x1b[27u'));
|
||||||
|
|
||||||
|
expect(higherPriorityHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'escape' }),
|
||||||
|
);
|
||||||
|
expect(lowerPriorityHandler).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'escape' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle double Escape', async () => {
|
it('should handle double Escape', async () => {
|
||||||
const keyHandler = vi.fn();
|
const keyHandler = vi.fn();
|
||||||
const { result } = await renderHookWithProviders(() =>
|
const { result } = await renderHookWithProviders(() =>
|
||||||
|
|||||||
Reference in New Issue
Block a user