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]);
const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>(
() => {},
);
const cancelHandlerRef = useRef<
(shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void
>(() => {});
const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => {
if (shouldRestorePrompt) {
setPendingRestorePrompt(true);
} else {
setPendingRestorePrompt(false);
cancelHandlerRef.current(false);
}
}, []);
const onCancelSubmit = useCallback(
(shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
if (shouldRestorePrompt) {
setPendingRestorePrompt(true);
} else {
setPendingRestorePrompt(false);
cancelHandlerRef.current(false, clearBuffer);
}
},
[],
);
useEffect(() => {
if (pendingRestorePrompt) {
@@ -1321,18 +1324,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
(shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => {
if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) {
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
// User is in control - preserve whatever text they typed, pasted, or restored
// If cancelling (shouldRestorePrompt=false):
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;
}
@@ -202,6 +202,6 @@ describe('useAgentStream', () => {
});
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 { useStateAndRef } from './useStateAndRef.js';
import { type MinimalTrackedToolCall } from './useTurnActivityMonitor.js';
import { useKeypress } from './useKeypress.js';
export interface UseAgentStreamOptions {
agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem'];
onCancelSubmit: (shouldRestorePrompt?: boolean) => void;
onCancelSubmit: (
shouldRestorePrompt?: boolean,
clearBuffer?: boolean,
) => void;
isShellFocused?: boolean;
logger?: Logger | null;
}
@@ -120,13 +124,16 @@ export const useAgentStream = ({
}
}, [addItem, pendingHistoryItemRef, setPendingHistoryItem]);
const cancelOngoingRequest = useCallback(async () => {
if (agent) {
await agent.abort();
setStreamingState(StreamingState.Idle);
onCancelSubmit(false);
}
}, [agent, onCancelSubmit]);
const cancelOngoingRequest = useCallback(
async (clearBuffer: boolean = true) => {
if (agent) {
await agent.abort();
setStreamingState(StreamingState.Idle);
onCancelSubmit(false, clearBuffer);
}
},
[agent, onCancelSubmit],
);
// TODO: Support native handleApprovalModeChange for Plan Mode
const handleApprovalModeChange = useCallback(
@@ -322,6 +329,21 @@ export const useAgentStream = ({
return () => unsubscribe?.();
}, [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(
async (
query: Part[] | string,
@@ -1637,7 +1637,7 @@ describe('useGeminiStream', () => {
simulateEscapeKeyPress();
expect(cancelSubmitSpy).toHaveBeenCalledWith(false);
expect(cancelSubmitSpy).toHaveBeenCalledWith(false, false);
});
it('should call setShellInputFocused(false) when escape is pressed', async () => {
+118 -86
View File
@@ -227,7 +227,10 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onCancelSubmit: (shouldRestorePrompt?: boolean) => void,
onCancelSubmit: (
shouldRestorePrompt?: boolean,
clearBuffer?: boolean,
) => void,
setShellInputFocused: (value: boolean) => void,
terminalWidth: number,
terminalHeight: number,
@@ -803,100 +806,129 @@ export const useGeminiStream = (
[addItem, config, isLowErrorVerbosity],
);
const cancelOngoingRequest = useCallback(() => {
if (
streamingState !== StreamingState.Responding &&
streamingState !== StreamingState.WaitingForConfirmation
) {
return;
}
if (turnCancelledRef.current) {
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);
const cancelOngoingRequest = useCallback(
(clearBuffer: boolean = true) => {
// If we are already cancelled, do nothing
if (turnCancelledRef.current) {
if (clearBuffer) {
onCancelSubmit(false, true);
}
return;
}
}
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);
const hasActiveTools = toolCalls.some(
(tc) =>
tc.status === CoreToolCallStatus.Executing ||
tc.status === CoreToolCallStatus.Scheduled ||
tc.status === CoreToolCallStatus.Validating,
);
// If we are not responding, not waiting for confirmation, and have no active tools,
// there is nothing to abort.
if (
streamingState === StreamingState.Idle &&
!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);
setShellInputFocused(false);
}, [
streamingState,
addItem,
setPendingHistoryItem,
onCancelSubmit,
pendingHistoryItemRef,
setShellInputFocused,
cancelAllToolCalls,
toolCalls,
activeShellPtyId,
setIsResponding,
]);
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) {
// 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(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
cancelOngoingRequest();
cancelOngoingRequest(false);
return true;
}
return false;
},
{
isActive: