fix(cli): prevent Escape from clearing input buffer (#17083) (#26339)

This commit is contained in:
Coco Sheng
2026-05-01 14:58:55 -04:00
committed by GitHub
parent b14a29efa2
commit 997f461cad
5 changed files with 172 additions and 115 deletions
+22 -19
View File
@@ -1127,18 +1127,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
} }
}, [config, historyManager]); }, [config, historyManager]);
const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>( const cancelHandlerRef = useRef<
() => {}, (shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void
); >(() => {});
const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => { const onCancelSubmit = useCallback(
if (shouldRestorePrompt) { (shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
setPendingRestorePrompt(true); if (shouldRestorePrompt) {
} else { setPendingRestorePrompt(true);
setPendingRestorePrompt(false); } else {
cancelHandlerRef.current(false); setPendingRestorePrompt(false);
} cancelHandlerRef.current(false, clearBuffer);
}, []); }
},
[],
);
useEffect(() => { useEffect(() => {
if (pendingRestorePrompt) { if (pendingRestorePrompt) {
@@ -1321,18 +1324,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
}); });
cancelHandlerRef.current = useCallback( cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => { (shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => {
if (isToolAwaitingConfirmation(pendingHistoryItems)) { if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) {
return; // Don't clear - user may be composing a follow-up message return; // Don't clear - user may be composing a follow-up message
} }
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Clear for Ctrl+C cancellation
return;
}
// If cancelling (shouldRestorePrompt=false), never modify the buffer // If cancelling (shouldRestorePrompt=false):
// User is in control - preserve whatever text they typed, pasted, or restored
if (!shouldRestorePrompt) { if (!shouldRestorePrompt) {
// Clear the buffer if explicitly requested (e.g., Ctrl+C)
if (clearBuffer) {
buffer.setText('');
}
// Otherwise (e.g., Escape), user is in control - preserve whatever text they typed
return; return;
} }
@@ -202,6 +202,6 @@ describe('useAgentStream', () => {
}); });
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled(); expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false); expect(mockOnCancelSubmit).toHaveBeenCalledWith(false, true);
}); });
}); });
+30 -8
View File
@@ -36,11 +36,15 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useSessionStats } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js'; import { useStateAndRef } from './useStateAndRef.js';
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js'; import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
import { useKeypress } from './useKeypress.js';
export interface UseAgentStreamOptions { export interface UseAgentStreamOptions {
agent?: AgentProtocol; agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem']; addItem: UseHistoryManagerReturn['addItem'];
onCancelSubmit: (shouldRestorePrompt?: boolean) => void; onCancelSubmit: (
shouldRestorePrompt?: boolean,
clearBuffer?: boolean,
) => void;
isShellFocused?: boolean; isShellFocused?: boolean;
logger?: Logger | null; logger?: Logger | null;
} }
@@ -120,13 +124,16 @@ export const useAgentStream = ({
} }
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]); }, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
const cancelOngoingRequest = useCallback(async () => { const cancelOngoingRequest = useCallback(
if (agent) { async (clearBuffer: boolean = true) => {
await agent.abort(); if (agent) {
setStreamingState(StreamingState.Idle); await agent.abort();
onCancelSubmit(false); setStreamingState(StreamingState.Idle);
} onCancelSubmit(false, clearBuffer);
}, [agent, onCancelSubmit]); }
},
[agent, onCancelSubmit],
);
// TODO: Support native handleApprovalModeChange for Plan Mode // TODO: Support native handleApprovalModeChange for Plan Mode
const handleApprovalModeChange = useCallback( const handleApprovalModeChange = useCallback(
@@ -322,6 +329,21 @@ export const useAgentStream = ({
return () => unsubscribe?.(); return () => unsubscribe?.();
}, [agent, handleEvent]); }, [agent, handleEvent]);
useKeypress(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
void cancelOngoingRequest(false);
return true;
}
return false;
},
{
isActive:
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation,
},
);
const submitQuery = useCallback( const submitQuery = useCallback(
async ( async (
query: Part[] | string, query: Part[] | string,
@@ -1637,7 +1637,7 @@ describe('useGeminiStream', () => {
simulateEscapeKeyPress(); simulateEscapeKeyPress();
expect(cancelSubmitSpy).toHaveBeenCalledWith(false); expect(cancelSubmitSpy).toHaveBeenCalledWith(false, false);
}); });
it('should call setShellInputFocused(false) when escape is pressed', async () => { it('should call setShellInputFocused(false) when escape is pressed', async () => {
+118 -86
View File
@@ -227,7 +227,10 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise<void>, performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean, modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>, setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onCancelSubmit: (shouldRestorePrompt?: boolean) => void, onCancelSubmit: (
shouldRestorePrompt?: boolean,
clearBuffer?: boolean,
) => void,
setShellInputFocused: (value: boolean) => void, setShellInputFocused: (value: boolean) => void,
terminalWidth: number, terminalWidth: number,
terminalHeight: number, terminalHeight: number,
@@ -803,100 +806,129 @@ export const useGeminiStream = (
[addItem, config, isLowErrorVerbosity], [addItem, config, isLowErrorVerbosity],
); );
const cancelOngoingRequest = useCallback(() => { const cancelOngoingRequest = useCallback(
if ( (clearBuffer: boolean = true) => {
streamingState !== StreamingState.Responding && // If we are already cancelled, do nothing
streamingState !== StreamingState.WaitingForConfirmation if (turnCancelledRef.current) {
) { if (clearBuffer) {
return; onCancelSubmit(false, true);
} }
if (turnCancelledRef.current) { return;
return;
}
turnCancelledRef.current = true;
setRetryStatus(null);
// A full cancellation means no tools have produced a final result yet.
// This determines if we show a generic "Request cancelled" message.
const isFullCancellation = !toolCalls.some(
(tc) => tc.status === 'success' || tc.status === 'error',
);
// Ensure we have an abort controller, creating one if it doesn't exist.
if (!abortControllerRef.current) {
abortControllerRef.current = new AbortController();
}
// The order is important here.
// 1. Fire the signal to interrupt any active async operations.
abortControllerRef.current.abort();
// 2. Call the imperative cancel to clear the queue of pending tools.
cancelAllToolCalls(abortControllerRef.current.signal);
if (pendingHistoryItemRef.current) {
const isShellCommand =
pendingHistoryItemRef.current.type === 'tool_group' &&
pendingHistoryItemRef.current.tools.some(
(t) => t.name === SHELL_COMMAND_NAME,
);
// If it is a shell command, we update the status to Canceled and clear the output
// to avoid artifacts, then add it to history immediately.
if (isShellCommand) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const toolGroup = pendingHistoryItemRef.current as HistoryItemToolGroup;
const updatedTools = toolGroup.tools.map((tool) => {
if (tool.name === SHELL_COMMAND_NAME) {
return {
...tool,
status: CoreToolCallStatus.Cancelled,
resultDisplay: tool.resultDisplay,
};
}
return tool;
});
addItem({ ...toolGroup, tools: updatedTools } as HistoryItemWithoutId);
} else {
addItem(pendingHistoryItemRef.current);
} }
}
setPendingHistoryItem(null);
// If it was a full cancellation, add the info message now. const hasActiveTools = toolCalls.some(
// Otherwise, we let handleCompletedTools figure out the next step, (tc) =>
// which might involve sending partial results back to the model. tc.status === CoreToolCallStatus.Executing ||
if (isFullCancellation) { tc.status === CoreToolCallStatus.Scheduled ||
// If shell is active, we delay this message to ensure correct ordering tc.status === CoreToolCallStatus.Validating,
// (Shell item first, then Info message). );
if (!activeShellPtyId) {
addItem({ // If we are not responding, not waiting for confirmation, and have no active tools,
type: MessageType.INFO, // there is nothing to abort.
text: 'Request cancelled.', if (
}); streamingState === StreamingState.Idle &&
setIsResponding(false); !isRespondingRef.current &&
!hasActiveTools
) {
// Even if we are "idle", if we are called with clearBuffer=true (Ctrl+C),
// we still want to clear the buffer.
if (clearBuffer) {
onCancelSubmit(false, true);
}
return;
} }
}
onCancelSubmit(false); turnCancelledRef.current = true;
setShellInputFocused(false); setRetryStatus(null);
}, [
streamingState, // A full cancellation means no tools have produced a final result yet.
addItem, // This determines if we show a generic "Request cancelled" message.
setPendingHistoryItem, const isFullCancellation = !toolCalls.some(
onCancelSubmit, (tc) => tc.status === 'success' || tc.status === 'error',
pendingHistoryItemRef, );
setShellInputFocused,
cancelAllToolCalls, // Ensure we have an abort controller, creating one if it doesn't exist.
toolCalls, if (!abortControllerRef.current) {
activeShellPtyId, abortControllerRef.current = new AbortController();
setIsResponding, }
]);
// The order is important here.
// 1. Fire the signal to interrupt any active async operations.
abortControllerRef.current.abort();
// 2. Call the imperative cancel to clear the queue of pending tools.
cancelAllToolCalls(abortControllerRef.current.signal);
if (pendingHistoryItemRef.current) {
// If it is a shell command, we update the status to Canceled and clear the output
// to avoid artifacts, then add it to history immediately.
if (
pendingHistoryItemRef.current.type === 'tool_group' &&
pendingHistoryItemRef.current.tools.some(
(t) => t.name === SHELL_COMMAND_NAME,
)
) {
const toolGroup = pendingHistoryItemRef.current;
const updatedTools = toolGroup.tools.map((tool) => {
if (tool.name === SHELL_COMMAND_NAME) {
return {
...tool,
status: CoreToolCallStatus.Cancelled,
resultDisplay: tool.resultDisplay,
};
}
return tool;
});
const newToolGroup: HistoryItemToolGroup = {
...toolGroup,
tools: updatedTools,
};
addItem(newToolGroup);
} else {
addItem(pendingHistoryItemRef.current);
}
}
setPendingHistoryItem(null);
// If it was a full cancellation, add the info message now.
// Otherwise, we let handleCompletedTools figure out the next step,
// which might involve sending partial results back to the model.
if (isFullCancellation) {
// If shell is active, we delay this message to ensure correct ordering
// (Shell item first, then Info message).
if (!activeShellPtyId) {
addItem({
type: MessageType.INFO,
text: 'Request cancelled.',
});
setIsResponding(false);
}
}
onCancelSubmit(false, clearBuffer);
setShellInputFocused(false);
},
[
streamingState,
addItem,
setPendingHistoryItem,
onCancelSubmit,
pendingHistoryItemRef,
isRespondingRef,
setShellInputFocused,
cancelAllToolCalls,
toolCalls,
activeShellPtyId,
setIsResponding,
],
);
useKeypress( useKeypress(
(key) => { (key) => {
if (key.name === 'escape' && !isShellFocused) { if (key.name === 'escape' && !isShellFocused) {
cancelOngoingRequest(); cancelOngoingRequest(false);
return true;
} }
return false;
}, },
{ {
isActive: isActive: