Merge pull request #1824 from QwenLM/mingholy/fix/esc-interrupt

refactor(cli): unify Escape key handling in AppContainer
This commit is contained in:
Mingholy 2026-02-13 21:39:11 +08:00 committed by GitHub
commit ffa2d89ecd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 51 deletions

View file

@ -692,7 +692,6 @@ export const AppContainer = (props: AppContainerProps) => {
terminalWidth,
terminalHeight,
handleVisionSwitchRequired, // onVisionSwitchRequired
embeddedShellFocused,
);
// Track whether suggestions are visible for Tab key handling
@ -900,6 +899,8 @@ export const AppContainer = (props: AppContainerProps) => {
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
const [escapePressedOnce, setEscapePressedOnce] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
@ -1176,6 +1177,47 @@ export const AppContainer = (props: AppContainerProps) => {
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
return;
} else if (keyMatchers[Command.ESCAPE](key)) {
// Escape key handling
// Skip if shell is focused (to allow shell's own escape handling)
if (embeddedShellFocused) {
return;
}
// If input has content, use double-press to clear
if (buffer.text.length > 0) {
if (escapePressedOnce) {
// Second press: clear input, keep the flag to allow immediate cancel
buffer.setText('');
return;
}
// First press: set flag and show prompt
setEscapePressedOnce(true);
escapeTimerRef.current = setTimeout(() => {
setEscapePressedOnce(false);
escapeTimerRef.current = null;
}, CTRL_EXIT_PROMPT_DURATION_MS);
return;
}
// Input is empty, cancel request immediately (no double-press needed)
if (streamingState === StreamingState.Responding) {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
cancelOngoingRequest?.();
setEscapePressedOnce(false);
return;
}
// No action available, reset the flag
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscapePressedOnce(false);
return;
}
let enteringConstrainHeightMode = false;
@ -1220,10 +1262,15 @@ export const AppContainer = (props: AppContainerProps) => {
ctrlCPressedOnce,
setCtrlCPressedOnce,
ctrlCTimerRef,
buffer.text.length,
ctrlDPressedOnce,
setCtrlDPressedOnce,
ctrlDTimerRef,
escapePressedOnce,
setEscapePressedOnce,
escapeTimerRef,
streamingState,
cancelOngoingRequest,
buffer,
handleSlashCommand,
activePtyId,
embeddedShellFocused,

View file

@ -9,7 +9,6 @@ import type { Mock, MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream } from './useGeminiStream.js';
import { useKeypress } from './useKeypress.js';
import * as atCommandProcessor from './atCommandProcessor.js';
import type {
TrackedToolCall,
@ -112,10 +111,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({
})),
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
@ -839,28 +834,8 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});
describe('User Cancellation', () => {
let keypressCallback: (key: any) => void;
const mockUseKeypress = useKeypress as Mock;
beforeEach(() => {
// Capture the callback passed to useKeypress
mockUseKeypress.mockImplementation((callback, options) => {
if (options.isActive) {
keypressCallback = callback;
} else {
keypressCallback = () => {};
}
});
});
const simulateEscapeKeyPress = () => {
act(() => {
keypressCallback({ name: 'escape' });
});
};
it('should cancel an in-progress stream when escape is pressed', async () => {
describe('Cancellation', () => {
it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => {
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
// Keep the stream open
@ -880,8 +855,10 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});
// Simulate escape key press
simulateEscapeKeyPress();
// Call cancelOngoingRequest directly
act(() => {
result.current.cancelOngoingRequest();
});
// Verify cancellation message is added
await waitFor(() => {
@ -898,7 +875,7 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should call onCancelSubmit handler when escape is pressed', async () => {
it('should call onCancelSubmit handler when cancelOngoingRequest is called', async () => {
const cancelSubmitSpy = vi.fn();
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@ -936,12 +913,14 @@ describe('useGeminiStream', () => {
result.current.submitQuery('test query');
});
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
expect(cancelSubmitSpy).toHaveBeenCalled();
});
it('should call setShellInputFocused(false) when escape is pressed', async () => {
it('should call setShellInputFocused(false) when cancelOngoingRequest is called', async () => {
const setShellInputFocusedSpy = vi.fn();
const mockStream = (async function* () {
yield { type: 'content', value: 'Part 1' };
@ -978,18 +957,22 @@ describe('useGeminiStream', () => {
result.current.submitQuery('test query');
});
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false);
});
it('should not do anything if escape is pressed when not responding', () => {
it('should not do anything if cancelOngoingRequest is called when not responding', () => {
const { result } = renderTestHook();
expect(result.current.streamingState).toBe(StreamingState.Idle);
// Simulate escape key press
simulateEscapeKeyPress();
// Call cancelOngoingRequest
act(() => {
result.current.cancelOngoingRequest();
});
// No change should happen, no cancellation message
expect(mockAddItem).not.toHaveBeenCalledWith(
@ -1024,7 +1007,9 @@ describe('useGeminiStream', () => {
});
// Cancel the request
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
// Allow the stream to continue
act(() => {
@ -1072,7 +1057,9 @@ describe('useGeminiStream', () => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
// Try to cancel
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
// Nothing should happen because the state is not `Responding`
expect(abortSpy).not.toHaveBeenCalled();

View file

@ -63,7 +63,6 @@ import {
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
@ -116,7 +115,6 @@ export const useGeminiStream = (
persistSessionModel?: string;
showGuidance?: boolean;
}>,
isShellFocused?: boolean,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -385,15 +383,6 @@ export const useGeminiStream = (
getPromptCount,
]);
useKeypress(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
cancelOngoingRequest();
}
},
{ isActive: streamingState === StreamingState.Responding },
);
const prepareQueryForGemini = useCallback(
async (
query: PartListUnion,