/ hooks / useCancelRequest.ts
useCancelRequest.ts
  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   */
  7  import { useCallback, useRef } from 'react'
  8  import { logEvent } from 'src/services/analytics/index.js'
  9  import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
 10  import {
 11    useAppState,
 12    useAppStateStore,
 13    useSetAppState,
 14  } from 'src/state/AppState.js'
 15  import { isVimModeEnabled } from '../components/PromptInput/utils.js'
 16  import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
 17  import type { SpinnerMode } from '../components/Spinner/types.js'
 18  import { useNotifications } from '../context/notifications.js'
 19  import { useIsOverlayActive } from '../context/overlayContext.js'
 20  import { useCommandQueue } from '../hooks/useCommandQueue.js'
 21  import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
 22  import { useKeybinding } from '../keybindings/useKeybinding.js'
 23  import type { Screen } from '../screens/REPL.js'
 24  import { exitTeammateView } from '../state/teammateViewHelpers.js'
 25  import {
 26    killAllRunningAgentTasks,
 27    markAgentsNotified,
 28  } from '../tasks/LocalAgentTask/LocalAgentTask.js'
 29  import type { PromptInputMode, VimMode } from '../types/textInputTypes.js'
 30  import {
 31    clearCommandQueue,
 32    enqueuePendingNotification,
 33    hasCommandsInQueue,
 34  } from '../utils/messageQueueManager.js'
 35  import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
 36  
 37  /** Time window in ms during which a second press kills all background agents. */
 38  const KILL_AGENTS_CONFIRM_WINDOW_MS = 3000
 39  
 40  type 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   */
 63  export 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  }