useCancelRequest.ts
hooks/useCancelRequest.ts
No strong subsystem tag
277
Lines
10127
Bytes
1
Exports
18
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 277 lines, 18 detected imports, and 1 detected exports.
Important relationships
Detected exports
CancelRequestHandler
Keywords
cancelchatctrltasksagentsabortsignalrunningescapecontextbackground
Detected imports
reactsrc/services/analytics/index.jssrc/services/analytics/metadata.jssrc/state/AppState.js../components/PromptInput/utils.js../components/permissions/PermissionRequest.js../components/Spinner/types.js../context/notifications.js../context/overlayContext.js../hooks/useCommandQueue.js../keybindings/shortcutFormat.js../keybindings/useKeybinding.js../screens/REPL.js../state/teammateViewHelpers.js../tasks/LocalAgentTask/LocalAgentTask.js../types/textInputTypes.js../utils/messageQueueManager.js../utils/sdkEventQueue.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* CancelRequestHandler component for handling cancel/escape keybinding.
*
* Must be rendered inside KeybindingSetup to have access to the keybinding context.
* This component renders nothing - it just registers the cancel keybinding handler.
*/
import { useCallback, useRef } from 'react'
import { logEvent } from 'src/services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
import {
useAppState,
useAppStateStore,
useSetAppState,
} from 'src/state/AppState.js'
import { isVimModeEnabled } from '../components/PromptInput/utils.js'
import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js'
import type { SpinnerMode } from '../components/Spinner/types.js'
import { useNotifications } from '../context/notifications.js'
import { useIsOverlayActive } from '../context/overlayContext.js'
import { useCommandQueue } from '../hooks/useCommandQueue.js'
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { Screen } from '../screens/REPL.js'
import { exitTeammateView } from '../state/teammateViewHelpers.js'
import {
killAllRunningAgentTasks,
markAgentsNotified,
} from '../tasks/LocalAgentTask/LocalAgentTask.js'
import type { PromptInputMode, VimMode } from '../types/textInputTypes.js'
import {
clearCommandQueue,
enqueuePendingNotification,
hasCommandsInQueue,
} from '../utils/messageQueueManager.js'
import { emitTaskTerminatedSdk } from '../utils/sdkEventQueue.js'
/** Time window in ms during which a second press kills all background agents. */
const KILL_AGENTS_CONFIRM_WINDOW_MS = 3000
type CancelRequestHandlerProps = {
setToolUseConfirmQueue: (
f: (toolUseConfirmQueue: ToolUseConfirm[]) => ToolUseConfirm[],
) => void
onCancel: () => void
onAgentsKilled: () => void
isMessageSelectorVisible: boolean
screen: Screen
abortSignal?: AbortSignal
popCommandFromQueue?: () => void
vimMode?: VimMode
isLocalJSXCommand?: boolean
isSearchingHistory?: boolean
isHelpOpen?: boolean
inputMode?: PromptInputMode
inputValue?: string
streamMode?: SpinnerMode
}
/**
* Component that handles cancel requests via keybinding.
* Renders null but registers the 'chat:cancel' keybinding handler.
*/
export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
const {
setToolUseConfirmQueue,
onCancel,
onAgentsKilled,
isMessageSelectorVisible,
screen,
abortSignal,
popCommandFromQueue,
vimMode,
isLocalJSXCommand,
isSearchingHistory,
isHelpOpen,
inputMode,
inputValue,
streamMode,
} = props
const store = useAppStateStore()
const setAppState = useSetAppState()
const queuedCommandsLength = useCommandQueue().length
const { addNotification, removeNotification } = useNotifications()
const lastKillAgentsPressRef = useRef<number>(0)
const viewSelectionMode = useAppState(s => s.viewSelectionMode)
const handleCancel = useCallback(() => {
const cancelProps = {
source:
'escape' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
streamMode:
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
}
// Priority 1: If there's an active task running, cancel it first
// This takes precedence over queue management so users can always interrupt Claude
if (abortSignal !== undefined && !abortSignal.aborted) {
logEvent('tengu_cancel', cancelProps)
setToolUseConfirmQueue(() => [])
onCancel()
return
}
// Priority 2: Pop queue when Claude is idle (no running task to cancel)
if (hasCommandsInQueue()) {
if (popCommandFromQueue) {
popCommandFromQueue()
return
}
}
// Fallback: nothing to cancel or pop (shouldn't reach here if isActive is correct)
logEvent('tengu_cancel', cancelProps)
setToolUseConfirmQueue(() => [])
onCancel()
}, [
abortSignal,
popCommandFromQueue,
setToolUseConfirmQueue,
onCancel,
streamMode,
])
// Determine if this handler should be active
// Other contexts (Transcript, HistorySearch, Help) have their own escape handlers
// Overlays (ModelPicker, ThinkingToggle, etc.) register themselves via useRegisterOverlay
// Local JSX commands (like /model, /btw) handle their own input
const isOverlayActive = useIsOverlayActive()
const canCancelRunningTask = abortSignal !== undefined && !abortSignal.aborted
const hasQueuedCommands = queuedCommandsLength > 0
// When in bash/background mode with empty input, escape should exit the mode
// rather than cancel the request. Let PromptInput handle mode exit.
// This only applies to Escape, not Ctrl+C which should always cancel.
const isInSpecialModeWithEmptyInput =
inputMode !== undefined && inputMode !== 'prompt' && !inputValue
// When viewing a teammate's transcript, let useBackgroundTaskNavigation handle Escape
const isViewingTeammate = viewSelectionMode === 'viewing-agent'
// Context guards: other screens/overlays handle their own cancel
const isContextActive =
screen !== 'transcript' &&
!isSearchingHistory &&
!isMessageSelectorVisible &&
!isLocalJSXCommand &&
!isHelpOpen &&
!isOverlayActive &&
!(isVimModeEnabled() && vimMode === 'INSERT')
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
// input, and to useBackgroundTaskNavigation when viewing a teammate
const isEscapeActive =
isContextActive &&
(canCancelRunningTask || hasQueuedCommands) &&
!isInSpecialModeWithEmptyInput &&
!isViewingTeammate
// Ctrl+C (app:interrupt): when viewing a teammate, stops everything and
// returns to main thread. Otherwise just handleCancel. Must NOT claim
// ctrl+c when main is idle at the prompt — that blocks the copy-selection
// handler and double-press-to-exit from ever seeing the keypress.
const isCtrlCActive =
isContextActive &&
(canCancelRunningTask || hasQueuedCommands || isViewingTeammate)
useKeybinding('chat:cancel', handleCancel, {
context: 'Chat',
isActive: isEscapeActive,
})
// Shared kill path: stop all agents, suppress per-agent notifications,
// emit SDK events, enqueue a single aggregate model-facing notification.
// Returns true if anything was killed.
const killAllAgentsAndNotify = useCallback((): boolean => {
const tasks = store.getState().tasks
const running = Object.entries(tasks).filter(
([, t]) => t.type === 'local_agent' && t.status === 'running',
)
if (running.length === 0) return false
killAllRunningAgentTasks(tasks, setAppState)
const descriptions: string[] = []
for (const [taskId, task] of running) {
markAgentsNotified(taskId, setAppState)
descriptions.push(task.description)
emitTaskTerminatedSdk(taskId, 'stopped', {
toolUseId: task.toolUseId,
summary: task.description,
})
}
const summary =
descriptions.length === 1
? `Background agent "${descriptions[0]}" was stopped by the user.`
: `${descriptions.length} background agents were stopped by the user: ${descriptions.map(d => `"${d}"`).join(', ')}.`
enqueuePendingNotification({ value: summary, mode: 'task-notification' })
onAgentsKilled()
return true
}, [store, setAppState, onAgentsKilled])
// Ctrl+C (app:interrupt). Scoped to teammate-view: killing agents from the
// main prompt stays a deliberate gesture (chat:killAgents), not a
// side-effect of cancelling a turn.
const handleInterrupt = useCallback(() => {
if (isViewingTeammate) {
killAllAgentsAndNotify()
exitTeammateView(setAppState)
}
if (canCancelRunningTask || hasQueuedCommands) {
handleCancel()
}
}, [
isViewingTeammate,
killAllAgentsAndNotify,
setAppState,
canCancelRunningTask,
hasQueuedCommands,
handleCancel,
])
useKeybinding('app:interrupt', handleInterrupt, {
context: 'Global',
isActive: isCtrlCActive,
})
// chat:killAgents uses a two-press pattern: first press shows a
// confirmation hint, second press within the window actually kills all
// agents. Reads tasks from the store directly to avoid stale closures.
const handleKillAgents = useCallback(() => {
const tasks = store.getState().tasks
const hasRunningAgents = Object.values(tasks).some(
t => t.type === 'local_agent' && t.status === 'running',
)
if (!hasRunningAgents) {
addNotification({
key: 'kill-agents-none',
text: 'No background agents running',
priority: 'immediate',
timeoutMs: 2000,
})
return
}
const now = Date.now()
const elapsed = now - lastKillAgentsPressRef.current
if (elapsed <= KILL_AGENTS_CONFIRM_WINDOW_MS) {
// Second press within window -- kill all background agents
lastKillAgentsPressRef.current = 0
removeNotification('kill-agents-confirm')
logEvent('tengu_cancel', {
source:
'kill_agents' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
clearCommandQueue()
killAllAgentsAndNotify()
return
}
// First press -- show confirmation hint in status bar
lastKillAgentsPressRef.current = now
const shortcut = getShortcutDisplay(
'chat:killAgents',
'Chat',
'ctrl+x ctrl+k',
)
addNotification({
key: 'kill-agents-confirm',
text: `Press ${shortcut} again to stop background agents`,
priority: 'immediate',
timeoutMs: KILL_AGENTS_CONFIRM_WINDOW_MS,
})
}, [store, addNotification, removeNotification, killAllAgentsAndNotify])
// Must stay always-active: ctrl+x is consumed as a chord prefix regardless
// of isActive (because ctrl+x ctrl+e is always live), so an inactive handler
// here would leak ctrl+k to readline kill-line. Handler gates internally.
useKeybinding('chat:killAgents', handleKillAgents, {
context: 'Chat',
})
return null
}