refactor(cli): unify Escape key handling in AppContainer

Consolidate Escape key behavior to improve UX and prevent conflicts:

- Move Escape handling from useGeminiStream to AppContainer
- Input with content: double-press to clear, then single-press to cancel
- Empty input: single-press immediately cancels ongoing request
- Preserve embeddedShellFocused check to allow shell's own escape handling
- Update tests to use cancelOngoingRequest directly instead of simulating keypress

Fixes inconsistent escape behavior between input clearing and request cancellation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-02-12 20:20:51 +08:00
parent 76d31d50c4
commit e7290c5d9a
3 changed files with 74 additions and 51 deletions

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,
@ -107,10 +106,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({
})),
}));
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
@ -850,28 +845,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
@ -891,8 +866,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(() => {
@ -909,7 +886,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' };
@ -947,12 +924,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' };
@ -989,18 +968,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(
@ -1035,7 +1018,9 @@ describe('useGeminiStream', () => {
});
// Cancel the request
simulateEscapeKeyPress();
act(() => {
result.current.cancelOngoingRequest();
});
// Allow the stream to continue
act(() => {
@ -1083,7 +1068,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();