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
+17 -14
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) => {
const onCancelSubmit = useCallback(
(shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
if (shouldRestorePrompt) {
setPendingRestorePrompt(true);
} else {
setPendingRestorePrompt(false);
cancelHandlerRef.current(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);
});
});
+26 -4
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 () => {
const cancelOngoingRequest = useCallback(
async (clearBuffer: boolean = true) => {
if (agent) {
await agent.abort();
setStreamingState(StreamingState.Idle);
onCancelSubmit(false);
onCancelSubmit(false, clearBuffer);
}
}, [agent, onCancelSubmit]);
},
[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 () => {
+53 -21
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,16 +806,38 @@ export const useGeminiStream = (
[addItem, config, isLowErrorVerbosity],
);
const cancelOngoingRequest = useCallback(() => {
if (
streamingState !== StreamingState.Responding &&
streamingState !== StreamingState.WaitingForConfirmation
) {
return;
}
const cancelOngoingRequest = useCallback(
(clearBuffer: boolean = true) => {
// If we are already cancelled, do nothing
if (turnCancelledRef.current) {
if (clearBuffer) {
onCancelSubmit(false, true);
}
return;
}
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;
}
turnCancelledRef.current = true;
setRetryStatus(null);
@@ -834,17 +859,15 @@ export const useGeminiStream = (
cancelAllToolCalls(abortControllerRef.current.signal);
if (pendingHistoryItemRef.current) {
const isShellCommand =
// 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,
);
// 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 toolGroup = pendingHistoryItemRef.current;
const updatedTools = toolGroup.tools.map((tool) => {
if (tool.name === SHELL_COMMAND_NAME) {
return {
@@ -855,7 +878,11 @@ export const useGeminiStream = (
}
return tool;
});
addItem({ ...toolGroup, tools: updatedTools } as HistoryItemWithoutId);
const newToolGroup: HistoryItemToolGroup = {
...toolGroup,
tools: updatedTools,
};
addItem(newToolGroup);
} else {
addItem(pendingHistoryItemRef.current);
}
@@ -877,26 +904,31 @@ export const useGeminiStream = (
}
}
onCancelSubmit(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: