mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-22 03:03:56 +00:00
Merge pull request #1824 from QwenLM/mingholy/fix/esc-interrupt
refactor(cli): unify Escape key handling in AppContainer
This commit is contained in:
commit
ffa2d89ecd
3 changed files with 74 additions and 51 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue