source dump of claude code
at main 276 lines 10 kB view raw
1/** 2 * CancelRequestHandler component for handling cancel/escape keybinding. 3 * 4 * Must be rendered inside KeybindingSetup to have access to the keybinding context. 5 * This component renders nothing - it just registers the cancel keybinding handler. 6 */ 7import { useCallback, useRef } from 'react' 8import { logEvent } from 'src/services/analytics/index.js' 9import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js' 10import { 11 useAppState, 12 useAppStateStore, 13 useSetAppState, 14} from 'src/state/AppState.js' 15import { isVimModeEnabled } from '../components/PromptInput/utils.js' 16import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' 17import type { SpinnerMode } from '../components/Spinner/types.js' 18import { useNotifications } from '../context/notifications.js' 19import { useIsOverlayActive } from '../context/overlayContext.js' 20import { useCommandQueue } from '../hooks/useCommandQueue.js' 21import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' 22import { useKeybinding } from '../keybindings/useKeybinding.js' 23import type { Screen } from '../screens/REPL.js' 24import { exitTeammateView } from '../state/teammateViewHelpers.js' 25import { 26 killAllRunningAgentTasks, 27 markAgentsNotified, 28} from '../tasks/LocalAgentTask/LocalAgentTask.js' 29import type { PromptInputMode, VimMode } from '../types/textInputTypes.js' 30import { 31 clearCommandQueue, 32 enqueuePendingNotification, 33 hasCommandsInQueue, 34} from '../utils/messageQueueManager.js' 35import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js' 36 37/** Time window in ms during which a second press kills all background agents. */ 38const KILL_AGENTS_CONFIRM_WINDOW_MS = 3000 39 40type CancelRequestHandlerProps = { 41 setToolUseConfirmQueue: ( 42 f: (toolUseConfirmQueue: ToolUseConfirm[]) => ToolUseConfirm[], 43 ) => void 44 onCancel: () => void 45 onAgentsKilled: () => void 46 isMessageSelectorVisible: boolean 47 screen: Screen 48 abortSignal?: AbortSignal 49 popCommandFromQueue?: () => void 50 vimMode?: VimMode 51 isLocalJSXCommand?: boolean 52 isSearchingHistory?: boolean 53 isHelpOpen?: boolean 54 inputMode?: PromptInputMode 55 inputValue?: string 56 streamMode?: SpinnerMode 57} 58 59/** 60 * Component that handles cancel requests via keybinding. 61 * Renders null but registers the 'chat:cancel' keybinding handler. 62 */ 63export function CancelRequestHandler(props: CancelRequestHandlerProps): null { 64 const { 65 setToolUseConfirmQueue, 66 onCancel, 67 onAgentsKilled, 68 isMessageSelectorVisible, 69 screen, 70 abortSignal, 71 popCommandFromQueue, 72 vimMode, 73 isLocalJSXCommand, 74 isSearchingHistory, 75 isHelpOpen, 76 inputMode, 77 inputValue, 78 streamMode, 79 } = props 80 const store = useAppStateStore() 81 const setAppState = useSetAppState() 82 const queuedCommandsLength = useCommandQueue().length 83 const { addNotification, removeNotification } = useNotifications() 84 const lastKillAgentsPressRef = useRef<number>(0) 85 const viewSelectionMode = useAppState(s => s.viewSelectionMode) 86 87 const handleCancel = useCallback(() => { 88 const cancelProps = { 89 source: 90 'escape' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 91 streamMode: 92 streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 93 } 94 95 // Priority 1: If there's an active task running, cancel it first 96 // This takes precedence over queue management so users can always interrupt Claude 97 if (abortSignal !== undefined && !abortSignal.aborted) { 98 logEvent('tengu_cancel', cancelProps) 99 setToolUseConfirmQueue(() => []) 100 onCancel() 101 return 102 } 103 104 // Priority 2: Pop queue when Claude is idle (no running task to cancel) 105 if (hasCommandsInQueue()) { 106 if (popCommandFromQueue) { 107 popCommandFromQueue() 108 return 109 } 110 } 111 112 // Fallback: nothing to cancel or pop (shouldn't reach here if isActive is correct) 113 logEvent('tengu_cancel', cancelProps) 114 setToolUseConfirmQueue(() => []) 115 onCancel() 116 }, [ 117 abortSignal, 118 popCommandFromQueue, 119 setToolUseConfirmQueue, 120 onCancel, 121 streamMode, 122 ]) 123 124 // Determine if this handler should be active 125 // Other contexts (Transcript, HistorySearch, Help) have their own escape handlers 126 // Overlays (ModelPicker, ThinkingToggle, etc.) register themselves via useRegisterOverlay 127 // Local JSX commands (like /model, /btw) handle their own input 128 const isOverlayActive = useIsOverlayActive() 129 const canCancelRunningTask = abortSignal !== undefined && !abortSignal.aborted 130 const hasQueuedCommands = queuedCommandsLength > 0 131 // When in bash/background mode with empty input, escape should exit the mode 132 // rather than cancel the request. Let PromptInput handle mode exit. 133 // This only applies to Escape, not Ctrl+C which should always cancel. 134 const isInSpecialModeWithEmptyInput = 135 inputMode !== undefined && inputMode !== 'prompt' && !inputValue 136 // When viewing a teammate's transcript, let useBackgroundTaskNavigation handle Escape 137 const isViewingTeammate = viewSelectionMode === 'viewing-agent' 138 // Context guards: other screens/overlays handle their own cancel 139 const isContextActive = 140 screen !== 'transcript' && 141 !isSearchingHistory && 142 !isMessageSelectorVisible && 143 !isLocalJSXCommand && 144 !isHelpOpen && 145 !isOverlayActive && 146 !(isVimModeEnabled() && vimMode === 'INSERT') 147 148 // Escape (chat:cancel) defers to mode-exit when in special mode with empty 149 // input, and to useBackgroundTaskNavigation when viewing a teammate 150 const isEscapeActive = 151 isContextActive && 152 (canCancelRunningTask || hasQueuedCommands) && 153 !isInSpecialModeWithEmptyInput && 154 !isViewingTeammate 155 156 // Ctrl+C (app:interrupt): when viewing a teammate, stops everything and 157 // returns to main thread. Otherwise just handleCancel. Must NOT claim 158 // ctrl+c when main is idle at the prompt — that blocks the copy-selection 159 // handler and double-press-to-exit from ever seeing the keypress. 160 const isCtrlCActive = 161 isContextActive && 162 (canCancelRunningTask || hasQueuedCommands || isViewingTeammate) 163 164 useKeybinding('chat:cancel', handleCancel, { 165 context: 'Chat', 166 isActive: isEscapeActive, 167 }) 168 169 // Shared kill path: stop all agents, suppress per-agent notifications, 170 // emit SDK events, enqueue a single aggregate model-facing notification. 171 // Returns true if anything was killed. 172 const killAllAgentsAndNotify = useCallback((): boolean => { 173 const tasks = store.getState().tasks 174 const running = Object.entries(tasks).filter( 175 ([, t]) => t.type === 'local_agent' && t.status === 'running', 176 ) 177 if (running.length === 0) return false 178 killAllRunningAgentTasks(tasks, setAppState) 179 const descriptions: string[] = [] 180 for (const [taskId, task] of running) { 181 markAgentsNotified(taskId, setAppState) 182 descriptions.push(task.description) 183 emitTaskTerminatedSdk(taskId, 'stopped', { 184 toolUseId: task.toolUseId, 185 summary: task.description, 186 }) 187 } 188 const summary = 189 descriptions.length === 1 190 ? `Background agent "${descriptions[0]}" was stopped by the user.` 191 : `${descriptions.length} background agents were stopped by the user: ${descriptions.map(d => `"${d}"`).join(', ')}.` 192 enqueuePendingNotification({ value: summary, mode: 'task-notification' }) 193 onAgentsKilled() 194 return true 195 }, [store, setAppState, onAgentsKilled]) 196 197 // Ctrl+C (app:interrupt). Scoped to teammate-view: killing agents from the 198 // main prompt stays a deliberate gesture (chat:killAgents), not a 199 // side-effect of cancelling a turn. 200 const handleInterrupt = useCallback(() => { 201 if (isViewingTeammate) { 202 killAllAgentsAndNotify() 203 exitTeammateView(setAppState) 204 } 205 if (canCancelRunningTask || hasQueuedCommands) { 206 handleCancel() 207 } 208 }, [ 209 isViewingTeammate, 210 killAllAgentsAndNotify, 211 setAppState, 212 canCancelRunningTask, 213 hasQueuedCommands, 214 handleCancel, 215 ]) 216 217 useKeybinding('app:interrupt', handleInterrupt, { 218 context: 'Global', 219 isActive: isCtrlCActive, 220 }) 221 222 // chat:killAgents uses a two-press pattern: first press shows a 223 // confirmation hint, second press within the window actually kills all 224 // agents. Reads tasks from the store directly to avoid stale closures. 225 const handleKillAgents = useCallback(() => { 226 const tasks = store.getState().tasks 227 const hasRunningAgents = Object.values(tasks).some( 228 t => t.type === 'local_agent' && t.status === 'running', 229 ) 230 if (!hasRunningAgents) { 231 addNotification({ 232 key: 'kill-agents-none', 233 text: 'No background agents running', 234 priority: 'immediate', 235 timeoutMs: 2000, 236 }) 237 return 238 } 239 const now = Date.now() 240 const elapsed = now - lastKillAgentsPressRef.current 241 if (elapsed <= KILL_AGENTS_CONFIRM_WINDOW_MS) { 242 // Second press within window -- kill all background agents 243 lastKillAgentsPressRef.current = 0 244 removeNotification('kill-agents-confirm') 245 logEvent('tengu_cancel', { 246 source: 247 'kill_agents' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 248 }) 249 clearCommandQueue() 250 killAllAgentsAndNotify() 251 return 252 } 253 // First press -- show confirmation hint in status bar 254 lastKillAgentsPressRef.current = now 255 const shortcut = getShortcutDisplay( 256 'chat:killAgents', 257 'Chat', 258 'ctrl+x ctrl+k', 259 ) 260 addNotification({ 261 key: 'kill-agents-confirm', 262 text: `Press ${shortcut} again to stop background agents`, 263 priority: 'immediate', 264 timeoutMs: KILL_AGENTS_CONFIRM_WINDOW_MS, 265 }) 266 }, [store, addNotification, removeNotification, killAllAgentsAndNotify]) 267 268 // Must stay always-active: ctrl+x is consumed as a chord prefix regardless 269 // of isActive (because ctrl+x ctrl+e is always live), so an inactive handler 270 // here would leak ctrl+k to readline kill-line. Handler gates internally. 271 useKeybinding('chat:killAgents', handleKillAgents, { 272 context: 'Chat', 273 }) 274 275 return null 276}