// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { feature } from 'bun:bundle'
import { spawnSync } from 'child_process'
import {
snapshotOutputTokensForTurn,
getCurrentTurnTokenBudget,
getTurnOutputTokens,
getBudgetContinuationCount,
getTotalInputTokens,
} from '../bootstrap/state.js'
import { parseTokenBudget } from '../utils/tokenBudget.js'
import { count } from '../utils/array.js'
import { dirname, join } from 'path'
import { tmpdir } from 'os'
import figures from 'figures'
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
import { useInput } from '../ink.js'
import { useSearchInput } from '../hooks/useSearchInput.js'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'
import type { JumpHandle } from '../components/VirtualMessageList.js'
import { renderMessagesToPlainText } from '../utils/exportRenderer.js'
import { openFileInExternalEditor } from '../utils/editor.js'
import { writeFile } from 'fs/promises'
import {
Box,
Text,
useStdin,
useTheme,
useTerminalFocus,
useTerminalTitle,
useTabStatus,
} from '../ink.js'
import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'
import { CostThresholdDialog } from '../components/CostThresholdDialog.js'
import { IdleReturnDialog } from '../components/IdleReturnDialog.js'
import * as React from 'react'
import {
useEffect,
useMemo,
useRef,
useState,
useCallback,
useDeferredValue,
useLayoutEffect,
type RefObject,
} from 'react'
import { useNotifications } from '../context/notifications.js'
import { sendNotification } from '../services/notifier.js'
import {
startPreventSleep,
stopPreventSleep,
} from '../services/preventSleep.js'
import { useTerminalNotification } from '../ink/useTerminalNotification.js'
import { hasCursorUpViewportYankBug } from '../ink/terminal.js'
import {
createFileStateCacheWithSizeLimit,
mergeFileStateCaches,
READ_FILE_STATE_CACHE_SIZE,
} from '../utils/fileStateCache.js'
import {
updateLastInteractionTime,
getLastInteractionTime,
getOriginalCwd,
getProjectRoot,
getSessionId,
switchSession,
setCostStateForRestore,
getTurnHookDurationMs,
getTurnHookCount,
resetTurnHookDuration,
getTurnToolDurationMs,
getTurnToolCount,
resetTurnToolDuration,
getTurnClassifierDurationMs,
getTurnClassifierCount,
resetTurnClassifierDuration,
} from '../bootstrap/state.js'
import { asSessionId, asAgentId } from '../types/ids.js'
import { logForDebugging } from '../utils/debug.js'
import { QueryGuard } from '../utils/QueryGuard.js'
import { useGoalAutoContinue } from '../hooks/useGoalAutoContinue.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { formatTokens, truncateToWidth } from '../utils/format.js'
import { consumeEarlyInput } from '../utils/earlyInput.js'
import { setMemberActive } from '../utils/swarm/teamHelpers.js'
import {
isSwarmWorker,
generateSandboxRequestId,
sendSandboxPermissionRequestViaMailbox,
sendSandboxPermissionResponseViaMailbox,
} from '../utils/swarm/permissionSync.js'
import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'
import { getTeamName, getAgentName } from '../utils/teammate.js'
import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'
import {
injectUserMessageToTeammate,
getAllInProcessTeammateTasks,
} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'
import {
isLocalAgentTask,
queuePendingMessage,
appendMessageToLocalAgent,
type LocalAgentTaskState,
} from '../tasks/LocalAgentTask/LocalAgentTask.js'
import {
registerLeaderToolUseConfirmQueue,
unregisterLeaderToolUseConfirmQueue,
registerLeaderSetToolPermissionContext,
unregisterLeaderSetToolPermissionContext,
} from '../utils/swarm/leaderPermissionBridge.js'
import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'
import { useLogMessages } from '../hooks/useLogMessages.js'
import { useFeishuBridge } from '../hooks/useFeishuBridge.js'
import { useReplBridge } from '../hooks/useReplBridge.js'
import {
type Command,
type CommandResultDisplay,
type ResumeEntrypoint,
getCommandName,
isCommandEnabled,
} from '../commands.js'
import type {
PromptInputMode,
QueuedCommand,
VimMode,
} from '../types/textInputTypes.js'
import {
MessageSelector,
selectableUserMessagesFilter,
messagesAfterAreOnlySynthetic,
} from '../components/MessageSelector.js'
import { useIdeLogging } from '../hooks/useIdeLogging.js'
import {
PermissionRequest,
type ToolUseConfirm,
} from '../components/permissions/PermissionRequest.js'
import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'
import { PromptDialog } from '../components/hooks/PromptDialog.js'
import type { PromptRequest, PromptResponse } from '../types/hooks.js'
import PromptInput from '../components/PromptInput/PromptInput.js'
import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'
import { useRemoteSession } from '../hooks/useRemoteSession.js'
import { useDirectConnect } from '../hooks/useDirectConnect.js'
import type { DirectConnectConfig } from '../server/directConnectManager.js'
import { useSSHSession } from '../hooks/useSSHSession.js'
import { useAssistantHistory } from '../hooks/useAssistantHistory.js'
import type { SSHSession } from '../ssh/createSSHSession.js'
import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'
import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'
import { useMoreRight } from '../moreright/useMoreRight.js'
import {
SpinnerWithVerb,
BriefIdleStatus,
type SpinnerMode,
} from '../components/Spinner.js'
import { getSystemPrompt } from '../constants/prompts.js'
import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'
import { getSystemContext, getUserContext } from '../context.js'
import { getMemoryFiles } from '../utils/claudemd.js'
import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'
import {
getTotalCost,
saveCurrentSessionCosts,
resetCostState,
getStoredSessionCosts,
} from '../cost-tracker.js'
import { useCostSummary } from '../costHook.js'
import { useFpsMetrics } from '../context/fpsMetrics.js'
import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'
import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'
import {
addToHistory,
removeLastFromHistory,
expandPastedTextRefs,
parseReferences,
} from '../history.js'
import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'
import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'
import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'
import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'
import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
import { CancelRequestHandler } from '../hooks/useCancelRequest.js'
import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'
import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'
import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'
import { errorMessage } from '../utils/errors.js'
import { isHumanTurn } from '../utils/messagePredicates.js'
import { logError } from '../utils/log.js'
// Dead code elimination: conditional imports
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration =
feature('VOICE_MODE')
? require('../hooks/useVoiceIntegration.js').useVoiceIntegration
: () => ({
stripTrailing: () => 0,
handleKeyEvent: () => {},
resetAnchor: () => {},
})
const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler =
feature('VOICE_MODE')
? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler
: () => null
// Frustration detection is ant-only (dogfooding). Conditional require so external
// builds eliminate the module entirely (including its two O(n) useMemos that run
// on every messages change, plus the GrowthBook fetch).
const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection =
"external" === 'ant'
? require('../components/FeedbackSurvey/useFrustrationDetection.js')
.useFrustrationDetection
: () => ({ state: 'closed', handleTranscriptSelect: () => {} })
// Ant-only org warning. Conditional require so the org UUID list is
// eliminated from external builds (one UUID is on excluded-strings).
const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification =
"external" === 'ant'
? require('../hooks/notifs/useAntOrgWarningNotification.js')
.useAntOrgWarningNotification
: () => {}
// Dead code elimination: conditional import for coordinator mode
const getCoordinatorUserContext: (
mcpClients: ReadonlyArray<{ name: string }>,
scratchpadDir?: string,
) => { [k: string]: string } = feature('COORDINATOR_MODE')
? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext
: () => ({})
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
import useCanUseTool from '../hooks/useCanUseTool.js'
import type { ToolPermissionContext, Tool } from '../Tool.js'
import {
applyPermissionUpdate,
applyPermissionUpdates,
persistPermissionUpdate,
} from '../utils/permissions/PermissionUpdate.js'
import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'
import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'
import {
getScratchpadDir,
isScratchpadEnabled,
} from '../utils/permissions/filesystem.js'
import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'
import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'
import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'
import type { AutoUpdaterResult } from '../utils/autoUpdater.js'
import {
getGlobalConfig,
saveGlobalConfig,
getGlobalConfigWriteCount,
} from '../utils/config.js'
import { hasConsoleBillingAccess } from '../utils/billing.js'
import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
} from 'src/services/analytics/index.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import {
textForResubmit,
handleMessageFromStream,
type StreamingToolUse,
type StreamingThinking,
isCompactBoundaryMessage,
getMessagesAfterCompactBoundary,
getContentText,
createUserMessage,
createAssistantMessage,
createTurnDurationMessage,
createAgentsKilledMessage,
createApiMetricsMessage,
createSystemMessage,
createCommandInputMessage,
formatCommandInputTags,
} from '../utils/messages.js'
import { generateSessionTitle } from '../utils/sessionTitle.js'
import {
BASH_INPUT_TAG,
COMMAND_MESSAGE_TAG,
COMMAND_NAME_TAG,
LOCAL_COMMAND_STDOUT_TAG,
} from '../constants/xml.js'
import { escapeXml } from '../utils/xml.js'
import type { ThinkingConfig } from '../utils/thinking.js'
import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'
import {
handlePromptSubmit,
type PromptInputHelpers,
} from '../utils/handlePromptSubmit.js'
import { useQueueProcessor } from '../hooks/useQueueProcessor.js'
import { useMailboxBridge } from '../hooks/useMailboxBridge.js'
import {
queryCheckpoint,
logQueryProfileReport,
} from '../utils/queryProfiler.js'
import type {
Message as MessageType,
UserMessage,
ProgressMessage,
HookResultMessage,
PartialCompactDirection,
} from '../types/message.js'
import { query } from '../query.js'
import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'
import { getQuerySourceForREPL } from '../utils/promptCategory.js'
import { useMergedTools } from '../hooks/useMergedTools.js'
import { mergeAndFilterTools } from '../utils/toolPool.js'
import { useMergedCommands } from '../hooks/useMergedCommands.js'
import { useSkillsChange } from '../hooks/useSkillsChange.js'
import { useManagePlugins } from '../hooks/useManagePlugins.js'
import { Messages } from '../components/Messages.js'
import { TaskListV2 } from '../components/TaskListV2.js'
import { TeammateViewHeader } from '../components/TeammateViewHeader.js'
import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import type { ScopedMcpServerConfig } from '../services/mcp/types.js'
import { randomUUID, type UUID } from 'crypto'
import { processSessionStartHooks } from '../utils/sessionStart.js'
import {
executeSessionEndHooks,
getSessionEndHookTimeoutMs,
} from '../utils/hooks.js'
import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js'
import { getTools, assembleToolPool } from '../tools.js'
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js'
import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js'
import { useMainLoopModel } from '../hooks/useMainLoopModel.js'
import {
useAppState,
useSetAppState,
useAppStateStore,
} from '../state/AppState.js'
import type {
ContentBlockParam,
ImageBlockParam,
} from '@anthropic-ai/sdk/resources/messages.mjs'
import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'
import type { PastedContent } from '../utils/config.js'
import {
copyPlanForFork,
copyPlanForResume,
getPlanSlug,
setPlanSlug,
} from '../utils/plans.js'
import {
clearSessionMetadata,
resetSessionFilePointer,
adoptResumedSessionFile,
removeTranscriptMessage,
restoreSessionMetadata,
getCurrentSessionTitle,
isEphemeralToolProgress,
isLoggableMessage,
saveWorktreeState,
getAgentTranscript,
} from '../utils/sessionStorage.js'
import { deserializeMessages } from '../utils/conversationRecovery.js'
import {
extractReadFilesFromMessages,
extractBashToolsFromMessages,
} from '../utils/queryHelpers.js'
import { resetMicrocompactState } from '../services/compact/microCompact.js'
import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'
import {
provisionContentReplacementState,
reconstructContentReplacementState,
type ContentReplacementRecord,
} from '../utils/toolResultStorage.js'
import { partialCompactConversation } from '../services/compact/compact.js'
import type { LogOption } from '../types/logs.js'
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
import {
fileHistoryMakeSnapshot,
type FileHistoryState,
fileHistoryRewind,
type FileHistorySnapshot,
copyFileHistoryForResume,
fileHistoryEnabled,
fileHistoryHasAnyChanges,
} from '../utils/fileHistory.js'
import {
type AttributionState,
incrementPromptCount,
} from '../utils/commitAttribution.js'
import { recordAttributionSnapshot } from '../utils/sessionStorage.js'
import {
computeStandaloneAgentContext,
restoreAgentFromSession,
restoreSessionStateFromLog,
restoreWorktreeForResume,
exitRestoredWorktree,
} from '../utils/sessionRestore.js'
import {
isBgSession,
updateSessionName,
updateSessionActivity,
} from '../utils/concurrentSessions.js'
import {
isInProcessTeammateTask,
type InProcessTeammateTaskState,
} from '../tasks/InProcessTeammateTask/types.js'
import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'
import { useInboxPoller } from '../hooks/useInboxPoller.js'
// Dead code elimination: conditional import for loop mode
/* eslint-disable @typescript-eslint/no-require-imports */
const proactiveModule =
feature('PROACTIVE') || feature('KAIROS')
? require('../proactive/index.js')
: null
const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}
const PROACTIVE_FALSE = () => false
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false
const useProactive =
feature('PROACTIVE') || feature('KAIROS')
? require('../proactive/useProactive.js').useProactive
: null
const useScheduledTasks = feature('AGENT_TRIGGERS')
? require('../hooks/useScheduledTasks.js').useScheduledTasks
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'
import type {
SandboxAskCallback,
NetworkHostPattern,
} from '../utils/sandbox/sandbox-adapter.js'
import {
type IDEExtensionInstallationStatus,
closeOpenDiffs,
getConnectedIdeClient,
type IdeType,
} from '../utils/ide.js'
import { useIDEIntegration } from '../hooks/useIDEIntegration.js'
import exit from '../commands/exit/index.js'
import { ExitFlow } from '../components/ExitFlow.js'
import { getCurrentWorktreeSession } from '../utils/worktree.js'
import {
popAllEditable,
enqueue,
type SetAppState,
getCommandQueue,
getCommandQueueLength,
removeByFilter,
} from '../utils/messageQueueManager.js'
import { useCommandQueue } from '../hooks/useCommandQueue.js'
import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'
import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'
import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js'
import { diagnosticTracker } from '../services/diagnosticTracking.js'
import {
handleSpeculationAccept,
type ActiveSpeculationState,
} from '../services/PromptSuggestion/speculation.js'
import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js'
import {
EffortCallout,
shouldShowEffortCallout,
} from '../components/EffortCallout.js'
import type { EffortValue } from '../utils/effort.js'
import { RemoteCallout } from '../components/RemoteCallout.js'
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
const AntModelSwitchCallout =
"external" === 'ant'
? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout
: null
const shouldShowAntModelSwitch =
"external" === 'ant'
? require('../components/AntModelSwitchCallout.js')
.shouldShowModelSwitchCallout
: (): boolean => false
const UndercoverAutoCallout =
"external" === 'ant'
? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout
: null
/* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
import { activityManager } from '../utils/activityManager.js'
import { createAbortController } from '../utils/abortController.js'
import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'
import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'
import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'
import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'
import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'
import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'
import { useAwaySummary } from 'src/hooks/useAwaySummary.js'
import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'
import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js'
import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js'
import {
getTipToShowOnSpinner,
recordShownTip,
} from 'src/services/tips/tipScheduler.js'
import type { Theme } from 'src/utils/theme.js'
import {
checkAndDisableBypassPermissionsIfNeeded,
checkAndDisableAutoModeIfNeeded,
useKickOffCheckAndDisableBypassPermissionsIfNeeded,
useKickOffCheckAndDisableAutoModeIfNeeded,
} from 'src/utils/permissions/bypassPermissionsKillswitch.js'
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'
import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js'
import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js'
import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'
import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'
import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'
import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'
import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'
import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'
import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'
import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js'
import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js'
import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js'
import {
DesktopUpsellStartup,
shouldShowDesktopUpsellStartup,
} from 'src/components/DesktopUpsell/DesktopUpsellStartup.js'
import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js'
import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js'
import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js'
import { UserTextMessage } from 'src/components/messages/UserTextMessage.js'
import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js'
import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js'
import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js'
import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js'
import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js'
import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js'
import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'
import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'
import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'
import {
AutoRunIssueNotification,
shouldAutoRunIssue,
getAutoRunIssueReasonText,
getAutoRunCommand,
type AutoRunIssueReason,
} from '../utils/autoRunIssue.js'
import type { HookProgress } from '../types/hooks.js'
import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL')
? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'
import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'
import {
CompanionSprite,
CompanionFloatingBubble,
MIN_COLS_FOR_FULL_SPRITE,
} from '../buddy/CompanionSprite.js'
import { DevBar } from '../components/DevBar.js'
// Session manager removed - using AppState now
import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'
import { REMOTE_SAFE_COMMANDS } from '../commands.js'
import type { RemoteMessageContent } from '../utils/teleport/api.js'
import {
FullscreenLayout,
useUnseenDivider,
computeUnseenDivider,
} from '../components/FullscreenLayout.js'
import {
isFullscreenEnvEnabled,
maybeGetTmuxMouseHint,
isMouseTrackingEnabled,
} from '../utils/fullscreen.js'
import { AlternateScreen } from '../ink/components/AlternateScreen.js'
import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'
import {
useMessageActions,
MessageActionsKeybindings,
MessageActionsBar,
type MessageActionsState,
type MessageActionsNav,
type MessageActionCaps,
} from '../components/messageActions.js'
import { setClipboard } from '../ink/termio/osc.js'
import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'
import {
createAttachmentMessage,
getQueuedCommandAttachments,
} from '../utils/attachments.js'
// Stable empty array for hooks that accept MCPServerConnection[] — avoids
// creating a new [] literal on every render in remote mode, which would
// cause useEffect dependency changes and infinite re-render loops.
const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []
// Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new
// function identity each render, which would break composedOnScroll's memo.
const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} }
// Window after a user-initiated scroll during which type-into-empty does NOT
// repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll
// up to read the start → start typing → before this fix, snapped to bottom.
// https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739
const RECENT_SCROLL_REPIN_WINDOW_MS = 3000
// Use LRU cache to prevent unbounded memory growth
// 100 files should be sufficient for most coding sessions while preventing
// memory issues when working across many files in large projects
function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0
? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2)
: sorted[mid]!
}
/**
* Small component to display transcript mode footer with dynamic keybinding.
* Must be rendered inside KeybindingSetup to access keybinding context.
*/
function TranscriptModeFooter({
showAllInTranscript,
virtualScroll,
searchBadge,
suppressShowAll = false,
status,
}: {
showAllInTranscript: boolean
virtualScroll: boolean
/** Minimap while navigating a closed-bar search. Shows n/N hints +
* right-aligned count instead of scroll hints. */
searchBadge?: { current: number; count: number }
/** Hide the ctrl+e hint. The [ dump path shares this footer with
* env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1),
* but ctrl+e only works in the env case — useGlobalKeybindings.tsx
* gates on !virtualScrollActive which is env-derived, doesn't know
* [ happened. */
suppressShowAll?: boolean
/** Transient status (v-for-editor progress). Notifications render inside
* PromptInput which isn't mounted in transcript — addNotification queues
* but nothing draws it. */
status?: string
}): React.ReactNode {
const toggleShortcut = useShortcutDisplay(
'app:toggleTranscript',
'Global',
'ctrl+o',
)
const showAllShortcut = useShortcutDisplay(
'transcript:toggleShowAll',
'Transcript',
'ctrl+e',
)
return (
Showing detailed transcript · {toggleShortcut} to toggle
{searchBadge
? ' · n/N to navigate'
: virtualScroll
? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom`
: suppressShowAll
? ''
: ` · ${showAllShortcut} to ${showAllInTranscript ? 'collapse' : 'show all'}`}
{status ? (
// v-for-editor render progress — transient, preempts the search
// badge since the user just pressed v and wants to see what's
// happening. Clears after 4s.
<>
{status}
>
) : searchBadge ? (
// Engine-counted — close enough for a rough location hint. May
// drift from render-count for ghost/phantom messages.
<>
{searchBadge.current}/{searchBadge.count}
{' '}
>
) : null}
)
}
/** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter
* so swapping them in the bottom slot doesn't shift ScrollBox height.
* useSearchInput handles readline editing; we report query changes and
* render the counter. Incremental — re-search + highlight per keystroke. */
function TranscriptSearchBar({
jumpRef,
count,
current,
onClose,
onCancel,
setHighlight,
initialQuery,
}: {
jumpRef: RefObject
count: number
current: number
/** Enter — commit. Query persists for n/N. */
onClose: (lastQuery: string) => void
/** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */
onCancel: () => void
setHighlight: (query: string) => void
// Seed with the previous query (less: / shows last pattern). Mount-fire
// of the effect re-scans with the same query — idempotent (same matches,
// nearest-ptr, same highlights). User can edit or clear.
initialQuery: string
}): React.ReactNode {
const { query, cursorOffset } = useSearchInput({
isActive: true,
initialQuery,
onExit: () => onClose(query),
onCancel,
})
// Index warm-up runs before the query effect so it measures the real
// cost — otherwise setSearchQuery fills the cache first and warm
// reports ~0ms while the user felt the actual lag.
// First / in a transcript session pays the extractSearchText cost.
// Subsequent / return 0 immediately (indexWarmed ref in VML).
// Transcript is frozen at ctrl+o so the cache stays valid.
// Initial 'building' so warmDone is false on mount — the [query] effect
// waits for the warm effect's first resolve instead of racing it. With
// null initial, warmDone would be true on mount → [query] fires →
// setSearchQuery fills cache → warm reports ~0ms while the user felt
// the real lag.
const [indexStatus, setIndexStatus] = React.useState<
'building' | { ms: number } | null
>('building')
React.useEffect(() => {
let alive = true
const warm = jumpRef.current?.warmSearchIndex
if (!warm) {
setIndexStatus(null) // VML not mounted yet — rare, skip indicator
return
}
setIndexStatus('building')
warm().then(ms => {
if (!alive) return
// <20ms = imperceptible. No point showing "indexed in 3ms".
if (ms < 20) {
setIndexStatus(null)
} else {
setIndexStatus({ ms })
setTimeout(() => alive && setIndexStatus(null), 2000)
}
})
return () => {
alive = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // mount-only: bar opens once per /
// Gate the query effect on warm completion. setHighlight stays instant
// (screen-space overlay, no indexing). setSearchQuery (the scan) waits.
const warmDone = indexStatus !== 'building'
useEffect(() => {
if (!warmDone) return
jumpRef.current?.setSearchQuery(query)
setHighlight(query)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, warmDone])
const off = cursorOffset
const cursorChar = off < query.length ? query[off] : ' '
return (
/
{query.slice(0, off)}
{cursorChar}
{off < query.length && {query.slice(off + 1)}}
{indexStatus === 'building' ? (
indexing…
) : indexStatus ? (
indexed in {indexStatus.ms}ms
) : count === 0 && query ? (
no matches
) : count > 0 ? (
// Engine-counted (indexOf on extractSearchText). May drift from
// render-count for ghost/phantom messages — badge is a rough
// location hint. scanElement gives exact per-message positions
// but counting ALL would cost ~1-3ms × matched-messages.
{current}/{count}
{' '}
) : null}
)
}
const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']
const TITLE_STATIC_PREFIX = '✳'
const TITLE_ANIMATION_INTERVAL_MS = 960
/**
* Sets the terminal tab title, with an animated prefix glyph while a query
* is running. Isolated from REPL so the 960ms animation tick re-renders only
* this leaf component (which returns null — pure side-effect) instead of the
* entire REPL tree. Before extraction, the tick was ~1 REPL render/sec for
* the duration of every turn, dragging PromptInput and friends along.
*/
function AnimatedTerminalTitle({
isAnimating,
title,
disabled,
noPrefix,
}: {
isAnimating: boolean
title: string
disabled: boolean
noPrefix: boolean
}): null {
const terminalFocused = useTerminalFocus()
const [frame, setFrame] = useState(0)
useEffect(() => {
if (disabled || noPrefix || !isAnimating || !terminalFocused) return
const interval = setInterval(
setFrame => setFrame(f => (f + 1) % TITLE_ANIMATION_FRAMES.length),
TITLE_ANIMATION_INTERVAL_MS,
setFrame,
)
return () => clearInterval(interval)
}, [disabled, noPrefix, isAnimating, terminalFocused])
const prefix = isAnimating
? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX)
: TITLE_STATIC_PREFIX
useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`)
return null
}
export type Props = {
commands: Command[]
debug: boolean
initialTools: Tool[]
// Initial messages to populate the REPL with
initialMessages?: MessageType[]
// Deferred hook messages promise — REPL renders immediately and injects
// hook messages when they resolve. Awaited before the first API call.
pendingHookMessages?: Promise
initialFileHistorySnapshots?: FileHistorySnapshot[]
// Content-replacement records from a resumed session's transcript — used to
// reconstruct contentReplacementState so the same results are re-replaced
initialContentReplacements?: ContentReplacementRecord[]
// Initial agent context for session resume (name/color set via /rename or /color)
initialAgentName?: string
initialAgentColor?: AgentColorName
mcpClients?: MCPServerConnection[]
dynamicMcpConfig?: Record
autoConnectIdeFlag?: boolean
strictMcpConfig?: boolean
systemPrompt?: string
appendSystemPrompt?: string
// Optional callback invoked before query execution
// Called after user message is added to conversation but before API call
// Return false to prevent query execution
onBeforeQuery?: (
input: string,
newMessages: MessageType[],
) => Promise
// Optional callback when a turn completes (model finishes responding)
onTurnComplete?: (messages: MessageType[]) => void | Promise
// When true, disables REPL input (hides prompt and prevents message selector)
disabled?: boolean
// Optional agent definition to use for the main thread
mainThreadAgentDefinition?: AgentDefinition
// When true, disables all slash commands
disableSlashCommands?: boolean
// Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks.
taskListId?: string
// Remote session config for --remote mode (uses CCR as execution engine)
remoteSessionConfig?: RemoteSessionConfig
// Direct connect config for `claude connect` mode (connects to a claude server)
directConnectConfig?: DirectConnectConfig
// SSH session for `claude ssh` mode (local REPL, remote tools over ssh)
sshSession?: SSHSession
// Thinking configuration to use when thinking is enabled
thinkingConfig: ThinkingConfig
}
export type Screen = 'prompt' | 'transcript'
export function REPL({
commands: initialCommands,
debug,
initialTools,
initialMessages,
pendingHookMessages,
initialFileHistorySnapshots,
initialContentReplacements,
initialAgentName,
initialAgentColor,
mcpClients: initialMcpClients,
dynamicMcpConfig: initialDynamicMcpConfig,
autoConnectIdeFlag,
strictMcpConfig = false,
systemPrompt: customSystemPrompt,
appendSystemPrompt,
onBeforeQuery,
onTurnComplete,
disabled = false,
mainThreadAgentDefinition: initialMainThreadAgentDefinition,
disableSlashCommands = false,
taskListId,
remoteSessionConfig,
directConnectConfig,
sshSession,
thinkingConfig,
}: Props): React.ReactNode {
const isRemoteSession = !!remoteSessionConfig
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+
// includes, and these were on the render path (hot during PageUp spam).
const titleDisabled = useMemo(
() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE),
[],
)
const moreRightEnabled = useMemo(
() =>
"external" === 'ant' &&
isEnvTruthy(process.env.CLAUDE_MORERIGHT),
[],
)
const disableVirtualScroll = useMemo(
() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL),
[],
)
const disableMessageActions = feature('MESSAGE_ACTIONS')
? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useMemo(
() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS),
[],
)
: false
// Log REPL mount/unmount lifecycle
useEffect(() => {
logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`)
return () => logForDebugging(`[REPL:unmount] REPL unmounting`)
}, [disabled])
// Agent definition is state so /resume can update it mid-session
const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(
initialMainThreadAgentDefinition,
)
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
const verbose = useAppState(s => s.verbose)
const mcp = useAppState(s => s.mcp)
const plugins = useAppState(s => s.plugins)
const agentDefinitions = useAppState(s => s.agentDefinitions)
const fileHistory = useAppState(s => s.fileHistory)
const initialMessage = useAppState(s => s.initialMessage)
const queuedCommands = useCommandQueue()
// feature() is a build-time constant — dead code elimination removes the hook
// call entirely in external builds, so this is safe despite looking conditional.
// These fields contain excluded strings that must not appear in external builds.
const spinnerTip = useAppState(s => s.spinnerTip)
const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks'
const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest)
const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest)
const teamContext = useAppState(s => s.teamContext)
const tasks = useAppState(s => s.tasks)
const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions)
const elicitation = useAppState(s => s.elicitation)
const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice)
const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending)
const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
const setAppState = useSetAppState()
// Bootstrap: retained local_agent that hasn't loaded disk yet → read
// sidechain JSONL and UUID-merge with whatever stream has appended so far.
// Stream appends immediately on retain (no defer); bootstrap fills the
// prefix. Disk-write-before-yield means live is always a suffix of disk.
const viewedLocalAgent = viewingAgentTaskId
? tasks[viewingAgentTaskId]
: undefined
const needsBootstrap =
isLocalAgentTask(viewedLocalAgent) &&
viewedLocalAgent.retain &&
!viewedLocalAgent.diskLoaded
useEffect(() => {
if (!viewingAgentTaskId || !needsBootstrap) return
const taskId = viewingAgentTaskId
void getAgentTranscript(asAgentId(taskId)).then(result => {
setAppState(prev => {
const t = prev.tasks[taskId]
if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev
const live = t.messages ?? []
const liveUuids = new Set(live.map(m => m.uuid))
const diskOnly = result
? result.messages.filter(m => !liveUuids.has(m.uuid))
: []
return {
...prev,
tasks: {
...prev.tasks,
[taskId]: {
...t,
messages: [...diskOnly, ...live],
diskLoaded: true,
},
},
}
})
})
}, [viewingAgentTaskId, needsBootstrap, setAppState])
const store = useAppStateStore()
const terminal = useTerminalNotification()
const mainLoopModel = useMainLoopModel()
// Note: standaloneAgentContext is initialized in main.tsx (via initialState) or
// ResumeConversation.tsx (via setAppState before rendering REPL) to avoid
// useEffect-based state initialization on mount (per CLAUDE.md guidelines)
// Local state for commands (hot-reloadable when skill files change)
const [localCommands, setLocalCommands] = useState(initialCommands)
// Watch for skill file changes and reload all commands
useSkillsChange(
isRemoteSession ? undefined : getProjectRoot(),
setLocalCommands,
)
// Track proactive mode for tools dependency - SleepTool filters by proactive state
const proactiveActive = React.useSyncExternalStore(
proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE,
proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE,
)
// BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which
// /brief flips mid-session alongside isBriefOnly. The memo below needs a
// React-visible dep to re-run getTools() when that happens; isBriefOnly is
// the AppState mirror that triggers the re-render. Without this, toggling
// /brief mid-session leaves the stale tool list (no SendUserMessage) and
// the model emits plain text the brief filter hides.
const isBriefOnly = useAppState(s => s.isBriefOnly)
const localTools = useMemo(
() => getTools(toolPermissionContext),
[toolPermissionContext, proactiveActive, isBriefOnly],
)
useKickOffCheckAndDisableBypassPermissionsIfNeeded()
useKickOffCheckAndDisableAutoModeIfNeeded()
const [dynamicMcpConfig, setDynamicMcpConfig] = useState<
Record | undefined
>(initialDynamicMcpConfig)
const onChangeDynamicMcpConfig = useCallback(
(config: Record) => {
setDynamicMcpConfig(config)
},
[setDynamicMcpConfig],
)
const [screen, setScreen] = useState('prompt')
const [showAllInTranscript, setShowAllInTranscript] = useState(false)
// [ forces the dump-to-scrollback path inside transcript mode. Separate
// from CLAUDE_CODE_NO_FLICKER=0 (which is process-lifetime) — this is
// ephemeral, reset on transcript exit. Diagnostic escape hatch so
// terminal/tmux native cmd-F can search the full flat render.
const [dumpMode, setDumpMode] = useState(false)
// v-for-editor render progress. Inline in the footer — notifications
// render inside PromptInput which isn't mounted in transcript.
const [editorStatus, setEditorStatus] = useState('')
// Incremented on transcript exit. Async v-render captures this at start;
// each status write no-ops if stale (user left transcript mid-render —
// the stable setState would otherwise stamp a ghost toast into the next
// session). Also clears any pending 4s auto-clear.
const editorGenRef = useRef(0)
const editorTimerRef = useRef | undefined>(
undefined,
)
const editorRenderingRef = useRef(false)
const { addNotification, removeNotification } = useNotifications()
// eslint-disable-next-line prefer-const
let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP
const mcpClients = useMergedClients(initialMcpClients, mcp.clients)
// IDE integration
const [ideSelection, setIDESelection] = useState(
undefined,
)
const [ideToInstallExtension, setIDEToInstallExtension] =
useState(null)
const [ideInstallationStatus, setIDEInstallationStatus] =
useState(null)
const [showIdeOnboarding, setShowIdeOnboarding] = useState(false)
// Dead code elimination: model switch callout state (ant-only)
const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => {
if ("external" === 'ant') {
return shouldShowAntModelSwitch()
}
return false
})
const [showEffortCallout, setShowEffortCallout] = useState(() =>
shouldShowEffortCallout(mainLoopModel),
)
const showRemoteCallout = useAppState(s => s.showRemoteCallout)
const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() =>
shouldShowDesktopUpsellStartup(),
)
// notifications
useModelMigrationNotifications()
useCanSwitchToExistingSubscription()
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus })
useMcpConnectivityStatus({ mcpClients })
useAutoModeUnavailableNotification()
usePluginInstallationStatus()
usePluginAutoupdateNotification()
useSettingsErrors()
useRateLimitWarningNotification(mainLoopModel)
useFastModeNotification()
useDeprecationWarningNotification(mainLoopModel)
useNpmDeprecationNotification()
useAntOrgWarningNotification()
useInstallMessages()
useChromeExtensionNotification()
useOfficialMarketplaceNotification()
useLspInitializationNotification()
useTeammateLifecycleNotification()
const {
recommendation: lspRecommendation,
handleResponse: handleLspResponse,
} = useLspPluginRecommendation()
const {
recommendation: hintRecommendation,
handleResponse: handleHintResponse,
} = useClaudeCodeHintRecommendation()
// Memoize the combined initial tools array to prevent reference changes
const combinedInitialTools = useMemo(() => {
return [...localTools, ...initialTools]
}, [localTools, initialTools])
// Initialize plugin management
useManagePlugins({ enabled: !isRemoteSession })
const tasksV2 = useTasksV2WithCollapseEffect()
// Start background plugin installations
// SECURITY: This code is guaranteed to run ONLY after the "trust this folder" dialog
// has been confirmed by the user. The trust dialog is shown in cli.tsx (line ~387)
// before the REPL component is rendered. The dialog blocks execution until the user
// accepts, and only then is the REPL component mounted and this effect runs.
// This ensures that plugin installations from repository and user settings only
// happen after explicit user consent to trust the current working directory.
useEffect(() => {
if (isRemoteSession) return
void performStartupChecks(setAppState)
}, [setAppState, isRemoteSession])
// Allow Claude in Chrome MCP to send prompts through MCP notifications
// and sync permission mode changes to the Chrome extension
usePromptsFromClaudeInChrome(
isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients,
toolPermissionContext.mode,
)
// Initialize swarm features: teammate hooks and context
// Handles both fresh spawns and resumed teammate sessions
useSwarmInitialization(setAppState, initialMessages, {
enabled: !isRemoteSession,
})
const mergedTools = useMergedTools(
combinedInitialTools,
mcp.tools,
toolPermissionContext,
)
// Apply agent tool restrictions if mainThreadAgentDefinition is set
const { tools, allowedAgentTypes } = useMemo(() => {
if (!mainThreadAgentDefinition) {
return {
tools: mergedTools,
allowedAgentTypes: undefined as string[] | undefined,
}
}
const resolved = resolveAgentTools(
mainThreadAgentDefinition,
mergedTools,
false,
true,
)
return {
tools: resolved.resolvedTools,
allowedAgentTypes: resolved.allowedAgentTypes,
}
}, [mainThreadAgentDefinition, mergedTools])
// Merge commands from local state, plugins, and MCP
const commandsWithPlugins = useMergedCommands(
localCommands,
plugins.commands as Command[],
)
const mergedCommands = useMergedCommands(
commandsWithPlugins,
mcp.commands as Command[],
)
// Filter out all commands if disableSlashCommands is true
const commands = useMemo(
() => (disableSlashCommands ? [] : mergedCommands),
[disableSlashCommands, mergedCommands],
)
useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients)
useIdeSelection(
isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients,
setIDESelection,
)
const [streamMode, setStreamMode] = useState('responding')
// Ref mirror so onSubmit can read the latest value without adding
// streamMode to its deps. streamMode flips between
// requesting/responding/tool-use ~10x per turn during streaming; having it
// in onSubmit's deps was recreating onSubmit on every flip, which
// cascaded into PromptInput prop churn and downstream useCallback/useMemo
// invalidation. The only consumers inside callbacks are debug logging and
// telemetry (handlePromptSubmit.ts), so a stale-by-one-render value is
// harmless — but ref mirrors sync on every render anyway so it's fresh.
const streamModeRef = useRef(streamMode)
streamModeRef.current = streamMode
const [streamingToolUses, setStreamingToolUses] = useState<
StreamingToolUse[]
>([])
const [streamingThinking, setStreamingThinking] =
useState(null)
// Auto-hide streaming thinking after 30 seconds of being completed
useEffect(() => {
if (
streamingThinking &&
!streamingThinking.isStreaming &&
streamingThinking.streamingEndedAt
) {
const elapsed = Date.now() - streamingThinking.streamingEndedAt
const remaining = 30000 - elapsed
if (remaining > 0) {
const timer = setTimeout(setStreamingThinking, remaining, null)
return () => clearTimeout(timer)
} else {
setStreamingThinking(null)
}
}
}, [streamingThinking])
const [abortController, setAbortController] =
useState(null)
// Ref that always points to the current abort controller, used by the
// REPL bridge to abort the active query when a remote interrupt arrives.
const abortControllerRef = useRef(null)
abortControllerRef.current = abortController
// Ref for the bridge result callback — set after useReplBridge initializes,
// read in the onQuery finally block to notify mobile clients that a turn ended.
const sendBridgeResultRef = useRef<() => void>(() => {})
// Ref for the synchronous restore callback — set after restoreMessageSync is
// defined, read in the onQuery finally block for auto-restore on interrupt.
const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {})
// Ref to the fullscreen layout's scroll box for keyboard scrolling.
// Null when fullscreen mode is disabled (ref never attached).
const scrollRef = useRef(null)
// Separate ref for the modal slot's inner ScrollBox — passed through
// FullscreenLayout → ModalContext so Tabs can attach it to its own
// ScrollBox for tall content (e.g. /status's MCP-server list). NOT
// keyboard-driven — ScrollKeybindingHandler stays on the outer ref so
// PgUp/PgDn/wheel always scroll the transcript behind the modal.
// Plumbing kept for future modal-scroll wiring.
const modalScrollRef = useRef(null)
// Timestamp of the last user-initiated scroll (wheel, PgUp/PgDn, ctrl+u,
// End/Home, G, drag-to-scroll). Stamped in composedOnScroll — the single
// chokepoint ScrollKeybindingHandler calls for every user scroll action.
// Programmatic scrolls (repinScroll's scrollToBottom, sticky auto-follow)
// do NOT go through composedOnScroll, so they don't stamp this. Ref not
// state: no re-render on every wheel tick.
const lastUserScrollTsRef = useRef(0)
// Synchronous state machine for the query lifecycle. Replaces the
// error-prone dual-state pattern where isLoading (React state, async
// batched) and isQueryRunning (ref, sync) could desync. See QueryGuard.ts.
const queryGuard = React.useRef(new QueryGuard()).current
// Subscribe to the guard — true during dispatching or running.
// This is the single source of truth for "is a local query in flight".
const isQueryActive = React.useSyncExternalStore(
queryGuard.subscribe,
queryGuard.getSnapshot,
)
// /goal auto-continuation: enqueues meta prompts when a goal is active and
// the query guard transitions from active -> idle.
useGoalAutoContinue(queryGuard)
// Separate loading flag for operations outside the local query guard:
// remote sessions (useRemoteSession / useDirectConnect) and foregrounded
// background tasks (useSessionBackgrounding). These don't route through
// onQuery / queryGuard, so they need their own spinner-visibility state.
// Initialize true if remote mode with initial prompt (CCR processing it).
const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(
remoteSessionConfig?.hasInitialPrompt ?? false,
)
// Derived: any loading source active. Read-only — no setter. Local query
// loading is driven by queryGuard (reserve/tryStart/end/cancelReservation),
// external loading by setIsExternalLoading.
const isLoading = isQueryActive || isExternalLoading
// Elapsed time is computed by SpinnerWithVerb from these refs on each
// animation frame, avoiding a useInterval that re-renders the entire REPL.
const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState<
string | undefined
>(undefined)
// messagesRef.current.length at the moment userInputOnProcessing was set.
// The placeholder hides once displayedMessages grows past this — i.e. the
// real user message has landed in the visible transcript.
const userInputBaselineRef = React.useRef(0)
// True while the submitted prompt is being processed but its user message
// hasn't reached setMessages yet. setMessages uses this to keep the
// baseline in sync when unrelated async messages (bridge status, hook
// results, scheduled tasks) land during that window.
const userMessagePendingRef = React.useRef(false)
// Wall-clock time tracking refs for accurate elapsed time calculation
const loadingStartTimeRef = React.useRef(0)
const totalPausedMsRef = React.useRef(0)
const pauseStartTimeRef = React.useRef(null)
const resetTimingRefs = React.useCallback(() => {
loadingStartTimeRef.current = Date.now()
totalPausedMsRef.current = 0
pauseStartTimeRef.current = null
}, [])
// Reset timing refs inline when isQueryActive transitions false→true.
// queryGuard.reserve() (in executeUserInput) fires BEFORE processUserInput's
// first await, but the ref reset in onQuery's try block runs AFTER. During
// that gap, React renders the spinner with loadingStartTimeRef=0, computing
// elapsedTimeMs = Date.now() - 0 ≈ 56 years. This inline reset runs on the
// first render where isQueryActive is observed true — the same render that
// first shows the spinner — so the ref is correct by the time the spinner
// reads it. See INC-4549.
const wasQueryActiveRef = React.useRef(false)
if (isQueryActive && !wasQueryActiveRef.current) {
resetTimingRefs()
}
wasQueryActiveRef.current = isQueryActive
// Wrapper for setIsExternalLoading that resets timing refs on transition
// to true — SpinnerWithVerb reads these for elapsed time, so they must be
// reset for remote sessions / foregrounded tasks too (not just local
// queries, which reset them in onQuery). Without this, a remote-only
// session would show ~56 years elapsed (Date.now() - 0).
const setIsExternalLoading = React.useCallback(
(value: boolean) => {
setIsExternalLoadingRaw(value)
if (value) resetTimingRefs()
},
[resetTimingRefs],
)
// Start time of the first turn that had swarm teammates running
// Used to compute total elapsed time (including teammate execution) for the deferred message
const swarmStartTimeRef = React.useRef(null)
const swarmBudgetInfoRef = React.useRef<
{ tokens: number; limit: number; nudges: number } | undefined
>(undefined)
// Ref to track current focusedInputDialog for use in callbacks
// This avoids stale closures when checking dialog state in timer callbacks
const focusedInputDialogRef =
React.useRef>(undefined)
// How long after the last keystroke before deferred dialogs are shown
const PROMPT_SUPPRESSION_MS = 1500
// True when user is actively typing — defers interrupt dialogs so keystrokes
// don't accidentally dismiss or answer a permission prompt the user hasn't read yet.
const [isPromptInputActive, setIsPromptInputActive] = React.useState(false)
const [autoUpdaterResult, setAutoUpdaterResult] =
useState(null)
useEffect(() => {
if (autoUpdaterResult?.notifications) {
autoUpdaterResult.notifications.forEach(notification => {
addNotification({
key: 'auto-updater-notification',
text: notification,
priority: 'low',
})
})
}
}, [autoUpdaterResult, addNotification])
// tmux + fullscreen + `mouse off`: one-time hint that wheel won't scroll.
// We no longer mutate tmux's session-scoped mouse option (it poisoned
// sibling panes); tmux users already know this tradeoff from vim/less.
useEffect(() => {
if (isFullscreenEnvEnabled()) {
void maybeGetTmuxMouseHint().then(hint => {
if (hint) {
addNotification({
key: 'tmux-mouse-hint',
text: hint,
priority: 'low',
})
}
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [showUndercoverCallout, setShowUndercoverCallout] = useState(false)
useEffect(() => {
if ("external" === 'ant') {
void (async () => {
// Wait for repo classification to settle (memoized, no-op if primed).
const { isInternalModelRepo } = await import(
'../utils/commitAttribution.js'
)
await isInternalModelRepo()
const { shouldShowUndercoverAutoNotice } = await import(
'../utils/undercover.js'
)
if (shouldShowUndercoverAutoNotice()) {
setShowUndercoverCallout(true)
}
})()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [toolJSX, setToolJSXInternal] = useState<{
jsx: React.ReactNode | null
shouldHidePromptInput: boolean
shouldContinueAnimation?: true
showSpinner?: boolean
isLocalJSXCommand?: boolean
isImmediate?: boolean
} | null>(null)
// Track local JSX commands separately so tools can't overwrite them.
// This enables "immediate" commands (like /btw) to persist while Claude is processing.
const localJSXCommandRef = useRef<{
jsx: React.ReactNode | null
shouldHidePromptInput: boolean
shouldContinueAnimation?: true
showSpinner?: boolean
isLocalJSXCommand: true
} | null>(null)
// Wrapper for setToolJSX that preserves local JSX commands (like /btw).
// When a local JSX command is active, we ignore updates from tools
// unless they explicitly set clearLocalJSX: true (from onDone callbacks).
//
// TO ADD A NEW IMMEDIATE COMMAND:
// 1. Set `immediate: true` in the command definition
// 2. Set `isLocalJSXCommand: true` when calling setToolJSX in the command's JSX
// 3. In the onDone callback, use `setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true })`
// to explicitly clear the overlay when the user dismisses it
const setToolJSX = useCallback(
(
args: {
jsx: React.ReactNode | null
shouldHidePromptInput: boolean
shouldContinueAnimation?: true
showSpinner?: boolean
isLocalJSXCommand?: boolean
clearLocalJSX?: boolean
} | null,
) => {
// If setting a local JSX command, store it in the ref
if (args?.isLocalJSXCommand) {
const { clearLocalJSX: _, ...rest } = args
localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true }
setToolJSXInternal(rest)
return
}
// If there's an active local JSX command in the ref
if (localJSXCommandRef.current) {
// Allow clearing only if explicitly requested (from onDone callbacks)
if (args?.clearLocalJSX) {
localJSXCommandRef.current = null
setToolJSXInternal(null)
return
}
// Otherwise, keep the local JSX command visible - ignore tool updates
return
}
// No active local JSX command, allow any update
if (args?.clearLocalJSX) {
setToolJSXInternal(null)
return
}
setToolJSXInternal(args)
},
[],
)
const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState<
ToolUseConfirm[]
>([])
// Sticky footer JSX registered by permission request components (currently
// only ExitPlanModePermissionRequest). Renders in FullscreenLayout's `bottom`
// slot so response options stay visible while the user scrolls a long plan.
const [permissionStickyFooter, setPermissionStickyFooter] =
useState(null)
const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] =
useState<
Array<{
hostPattern: NetworkHostPattern
resolvePromise: (allowConnection: boolean) => void
}>
>([])
const [promptQueue, setPromptQueue] = useState<
Array<{
request: PromptRequest
title: string
toolInputSummary?: string | null
resolve: (response: PromptResponse) => void
reject: (error: Error) => void
}>
>([])
const [questionRequest, setQuestionRequest] =
useState(null)
useEffect(() => {
const { questionService } = require('../services/question/questionService.js') as typeof import('../services/question/questionService.js')
// Bridge cleanup keyed by question request id, so a local reply/reject can
// cancel the remote prompt (and vice versa) whichever side answers first.
const bridgeCleanups = new Map void>()
const onAsked = (
request: import('../services/question/questionService.js').QuestionRequest,
) => {
setQuestionRequest(request)
// When the REPL bridge is connected, forward the question to the remote
// user (e.g. on claude.ai) as a can_use_tool control_request, racing it
// against the local overlay. Mirrors the sandbox permission pattern.
if (!feature('BRIDGE_MODE')) return
const bridgeCallbacks = store.getState().replBridgePermissionCallbacks
if (!bridgeCallbacks) return
const bridgeRequestId = randomUUID()
bridgeCallbacks.sendRequest(
bridgeRequestId,
'AskUserQuestion',
{ questions: request.questions },
request.id,
request.questions.map(q => q.question).join(' | '),
)
const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => {
bridgeCleanups.delete(request.id)
unsubscribe()
if (response.behavior === 'allow') {
// CCR returns per-question answers when it has a dedicated renderer;
// fall back to first option so a generic allow degrades gracefully.
const remote = response.updatedInput?.answers
const answers = request.questions.map((q, i) => {
const provided = Array.isArray(remote) ? remote[i] : undefined
if (Array.isArray(provided)) return provided.map(String)
if (typeof provided === 'string') return [provided]
const first = q.options[0]?.label
return first ? [first] : []
})
questionService.reply({ requestID: request.id, answers })
} else {
questionService.reject(request.id)
}
})
bridgeCleanups.set(request.id, () => {
unsubscribe()
bridgeCallbacks.cancelRequest(bridgeRequestId)
})
}
// Local (or remote) resolution: dismiss the overlay and tear down the
// matching remote prompt so it doesn't linger on the other side.
const onResolved = (payload: { requestID: string }) => {
setQuestionRequest(prev =>
prev && prev.id === payload.requestID ? null : prev,
)
const cleanup = bridgeCleanups.get(payload.requestID)
if (cleanup) {
bridgeCleanups.delete(payload.requestID)
cleanup()
}
}
questionService.events.on('asked', onAsked)
questionService.events.on('replied', onResolved)
questionService.events.on('rejected', onResolved)
return () => {
questionService.events.off('asked', onAsked)
questionService.events.off('replied', onResolved)
questionService.events.off('rejected', onResolved)
for (const cleanup of bridgeCleanups.values()) cleanup()
bridgeCleanups.clear()
}
}, [store])
// Track bridge cleanup functions for sandbox permission requests so the
// local dialog handler can cancel the remote prompt when the local user
// responds first. Keyed by host to support concurrent same-host requests.
const sandboxBridgeCleanupRef = useRef