🎯 Use case
This file lives under “screens/”, which covers full-screen React flows and primary UI routes. On the API surface it exposes Props, Screen, and REPL — mainly types, interfaces, or factory objects. Dependencies touch React UI, bun:bundle, subprocess spawning, and Node path helpers. It composes internal code from bootstrap, utils, ink, hooks, and components (relative imports).
Generated from folder role, exports, dependency roots, and inline comments — not hand-reviewed for every path.
🧠 Inline summary
import { c as _c } from "react/compiler-runtime"; // 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';
📤 Exports (heuristic)
PropsScreenREPL
📚 External import roots
Package roots from from "…" (relative paths omitted).
reactbun:bundlechild_processpathosfiguresfssrccrypto@anthropic-ai
🖥️ Source preview
⚠️ Preview truncated to ~400KB. Open the file locally for the full content.
⚠️ Syntax highlighting applies to the first ~150k characters only (performance); the raw preview above may be longer.
import { c as _c } from "react/compiler-runtime";
// 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 { 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 { 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(t0) {
const $ = _c(9);
const {
showAllInTranscript,
virtualScroll,
searchBadge,
suppressShowAll: t1,
status
} = t0;
const suppressShowAll = t1 === undefined ? false : t1;
const toggleShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o");
const showAllShortcut = useShortcutDisplay("transcript:toggleShowAll", "Transcript", "ctrl+e");
const t2 = searchBadge ? " \xB7 n/N to navigate" : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? "" : ` · ${showAllShortcut} to ${showAllInTranscript ? "collapse" : "show all"}`;
let t3;
if ($[0] !== t2 || $[1] !== toggleShortcut) {
t3 = <Text dimColor={true}>Showing detailed transcript · {toggleShortcut} to toggle{t2}</Text>;
$[0] = t2;
$[1] = toggleShortcut;
$[2] = t3;
} else {
t3 = $[2];
}
let t4;
if ($[3] !== searchBadge || $[4] !== status) {
t4 = status ? <><Box flexGrow={1} /><Text>{status} </Text></> : searchBadge ? <><Box flexGrow={1} /><Text dimColor={true}>{searchBadge.current}/{searchBadge.count}{" "}</Text></> : null;
$[3] = searchBadge;
$[4] = status;
$[5] = t4;
} else {
t4 = $[5];
}
let t5;
if ($[6] !== t3 || $[7] !== t4) {
t5 = <Box noSelect={true} alignItems="center" alignSelf="center" borderTopDimColor={true} borderBottom={false} borderLeft={false} borderRight={false} borderStyle="single" marginTop={1} paddingLeft={2} width="100%">{t3}{t4}</Box>;
$[6] = t3;
$[7] = t4;
$[8] = t5;
} else {
t5 = $[8];
}
return t5;
}
/** 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<JumpHandle | null>;
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 <Box borderTopDimColor borderBottom={false} borderLeft={false} borderRight={false} borderStyle="single" marginTop={1} paddingLeft={2} width="100%"
// applySearchHighlight scans the whole screen buffer. The query
// text rendered here IS on screen — /foo matches its own 'foo' in
// the bar. With no content matches that's the ONLY visible match →
// gets CURRENT → underlined. noSelect makes searchHighlight.ts:76
// skip these cells (same exclusion as gutters). You can't text-
// select the bar either; it's transient chrome, fine.
noSelect>
<Text>/</Text>
<Text>{query.slice(0, off)}</Text>
<Text inverse>{cursorChar}</Text>
{off < query.length && <Text>{query.slice(off + 1)}</Text>}
<Box flexGrow={1} />
{indexStatus === 'building' ? <Text dimColor>indexing… </Text> : indexStatus ? <Text dimColor>indexed in {indexStatus.ms}ms </Text> : count === 0 && query ? <Text color="error">no matches </Text> : 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.
<Text dimColor>
{current}/{count}
{' '}
</Text> : null}
</Box>;
}
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(t0) {
const $ = _c(6);
const {
isAnimating,
title,
disabled,
noPrefix
} = t0;
const terminalFocused = useTerminalFocus();
const [frame, setFrame] = useState(0);
let t1;
let t2;
if ($[0] !== disabled || $[1] !== isAnimating || $[2] !== noPrefix || $[3] !== terminalFocused) {
t1 = () => {
if (disabled || noPrefix || !isAnimating || !terminalFocused) {
return;
}
const interval = setInterval(_temp2, TITLE_ANIMATION_INTERVAL_MS, setFrame);
return () => clearInterval(interval);
};
t2 = [disabled, noPrefix, isAnimating, terminalFocused];
$[0] = disabled;
$[1] = isAnimating;
$[2] = noPrefix;
$[3] = terminalFocused;
$[4] = t1;
$[5] = t2;
} else {
t1 = $[4];
t2 = $[5];
}
useEffect(t1, t2);
const prefix = isAnimating ? TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX : TITLE_STATIC_PREFIX;
useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`);
return null;
}
function _temp2(setFrame_0) {
return setFrame_0(_temp);
}
function _temp(f) {
return (f + 1) % TITLE_ANIMATION_FRAMES.length;
}
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<HookResultMessage[]>;
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<string, ScopedMcpServerConfig>;
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<boolean>;
// Optional callback when a turn completes (model finishes responding)
onTurnComplete?: (messages: MessageType[]) => void | Promise<void>;
// 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<string, ScopedMcpServerConfig> | undefined>(initialDynamicMcpConfig);
const onChangeDynamicMcpConfig = useCallback((config: Record<string, ScopedMcpServerConfig>) => {
setDynamicMcpConfig(config);
}, [setDynamicMcpConfig]);
const [screen, setScreen] = useState<Screen>('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<ReturnType<typeof setTimeout> | 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<IDESelection | undefined>(undefined);
const [ideToInstallExtension, setIDEToInstallExtension] = useState<IdeType | null>(null);
const [ideInstallationStatus, setIDEInstallationStatus] = useState<IDEExtensionInstallationStatus | null>(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<SpinnerMode>('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<StreamingThinking | null>(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<AbortController | null>(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<AbortController | null>(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<ScrollBoxHandle>(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<ScrollBoxHandle>(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);
// 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<number>(0);
const totalPausedMsRef = React.useRef(0);
const pauseStartTimeRef = React.useRef<number | null>(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<number | null>(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<ReturnType<typeof getFocusedInputDialog>>(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<AutoUpdaterResult | null>(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<React.ReactNode | null>(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;
}>>([]);
// 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<Map<string, Array<() => void>>>(new Map());
// -- Terminal title management
// Session title (set via /rename or restored on resume) wins over
// the agent name, which wins over the Haiku-extracted topic;
// all fall back to the product name.
const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false;
const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined;
const [haikuTitle, setHaikuTitle] = useState<string>();
// Gates the one-shot Haiku call that generates the tab title. Seeded true
// on resume (initialMessages present) so we don't re-title a resumed
// session from mid-conversation context.
const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0);
const agentTitle = mainThreadAgentDefinition?.agentType;
const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code';
const isWaitingForApproval = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest;
// Local-jsx commands (like /plugin, /config) show user-facing dialogs that
// wait for input. Require jsx != null — if the flag is stuck true but jsx
// is null, treat as not-showing so TextInput focus and queue processor
// aren't deadlocked by a phantom overlay.
const isShowingLocalJSXCommand = toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null;
const titleIsAnimating = isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand;
// Title animation state lives in <AnimatedTerminalTitle> so the 960ms tick
// doesn't re-render REPL. titleDisabled/terminalTitle are still computed
// here because onQueryImpl reads them (background session description,
// haiku title extraction gate).
// Prevent macOS from sleeping while Claude is working
useEffect(() => {
if (isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand) {
startPreventSleep();
return () => stopPreventSleep();
}
}, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]);
const sessionStatus: TabStatusKind = isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting' : isLoading ? 'busy' : 'idle';
const waitingFor = sessionStatus !== 'waiting' ? undefined : toolUseConfirmQueue.length > 0 ? `approve ${toolUseConfirmQueue[0]!.tool.name}` : pendingWorkerRequest ? 'worker request' : pendingSandboxRequest ? 'sandbox request' : isShowingLocalJSXCommand ? 'dialog open' : 'input needed';
// Push status to the PID file for `claude ps`. Fire-and-forget; ps falls
// back to transcript-tail derivation when this is missing/stale.
useEffect(() => {
if (feature('BG_SESSIONS')) {
void updateSessionActivity({
status: sessionStatus,
waitingFor
});
}
}, [sessionStatus, waitingFor]);
// 3P default: off — OSC 21337 is ant-only while the spec stabilizes.
// Gated so we can roll back if the sidebar indicator conflicts with
// the title spinner in terminals that render both. When the flag is
// on, the user-facing config setting controls whether it's active.
const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false);
const showStatusInTerminalTab = tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false);
useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus);
// Register the leader's setToolUseConfirmQueue for in-process teammates
useEffect(() => {
registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue);
return () => unregisterLeaderToolUseConfirmQueue();
}, [setToolUseConfirmQueue]);
const [messages, rawSetMessages] = useState<MessageType[]>(initialMessages ?? []);
const messagesRef = useRef(messages);
// Stores the willowMode variant that was shown (or false if no hint shown).
// Captured at hint_shown time so hint_converted telemetry reports the same
// variant — the GrowthBook value shouldn't change mid-session, but reading
// it once guarantees consistency between the paired events.
const idleHintShownRef = useRef<string | false>(false);
// Wrap setMessages so messagesRef is always current the instant the
// call returns — not when React later processes the batch. Apply the
// updater eagerly against the ref, then hand React the computed value
// (not the function). rawSetMessages batching becomes last-write-wins,
// and the last write is correct because each call composes against the
// already-updated ref. This is the Zustand pattern: ref is source of
// truth, React state is the render projection. Without this, paths
// that queue functional updaters then synchronously read the ref
// (e.g. handleSpeculationAccept → onQuery) see stale data.
const setMessages = useCallback((action: React.SetStateAction<MessageType[]>) => {
const prev = messagesRef.current;
const next = typeof action === 'function' ? action(messagesRef.current) : action;
messagesRef.current = next;
if (next.length < userInputBaselineRef.current) {
// Shrank (compact/rewind/clear) — clamp so placeholderText's length
// check can't go stale.
userInputBaselineRef.current = 0;
} else if (next.length > prev.length && userMessagePendingRef.current) {
// Grew while the submitted user message hasn't landed yet. If the
// added messages don't include it (bridge status, hook results,
// scheduled tasks landing async during processUserInputBase), bump
// baseline so the placeholder stays visible. Once the user message
// lands, stop tracking — later additions (assistant stream) should
// not re-show the placeholder.
const delta = next.length - prev.length;
const added = prev.length === 0 || next[0] === prev[0] ? next.slice(-delta) : next.slice(0, delta);
if (added.some(isHumanTurn)) {
userMessagePendingRef.current = false;
} else {
userInputBaselineRef.current = next.length;
}
}
rawSetMessages(next);
}, []);
// Capture the baseline message count alongside the placeholder text so
// the render can hide it once displayedMessages grows past the baseline.
const setUserInputOnProcessing = useCallback((input: string | undefined) => {
if (input !== undefined) {
userInputBaselineRef.current = messagesRef.current.length;
userMessagePendingRef.current = true;
} else {
userMessagePendingRef.current = false;
}
setUserInputOnProcessingRaw(input);
}, []);
// Fullscreen: track the unseen-divider position. dividerIndex changes
// only ~twice/scroll-session (first scroll-away + repin). pillVisible
// and stickyPrompt now live in FullscreenLayout — they subscribe to
// ScrollBox directly so per-frame scroll never re-renders REPL.
const {
dividerIndex,
dividerYRef,
onScrollAway,
onRepin,
jumpToNew,
shiftDivider
} = useUnseenDivider(messages.length);
if (feature('AWAY_SUMMARY')) {
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAwaySummary(messages, setMessages, isLoading);
}
const [cursor, setCursor] = useState<MessageActionsState | null>(null);
const cursorNavRef = useRef<MessageActionsNav | null>(null);
// Memoized so Messages' React.memo holds.
const unseenDivider = useMemo(() => computeUnseenDivider(messages, dividerIndex),
// eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind
[dividerIndex, messages.length]);
// Re-pin scroll to bottom and clear the unseen-messages baseline. Called
// on any user-driven return-to-live action (submit, type-into-empty,
// overlay appear/dismiss).
const repinScroll = useCallback(() => {
scrollRef.current?.scrollToBottom();
onRepin();
setCursor(null);
}, [onRepin, setCursor]);
// Backstop for the submit-handler repin at onSubmit. If a buffered stdin
// event (wheel/drag) races between handler-fire and state-commit, the
// handler's scrollToBottom can be undone. This effect fires on the render
// where the user's message actually lands — tied to React's commit cycle,
// so it can't race with stdin. Keyed on lastMsg identity (not messages.length)
// so useAssistantHistory's prepends don't spuriously repin.
const lastMsg = messages.at(-1);
const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg);
useEffect(() => {
if (lastMsgIsHuman) {
repinScroll();
}
}, [lastMsgIsHuman, lastMsg, repinScroll]);
// Assistant-chat: lazy-load remote history on scroll-up. No-op unless
// KAIROS build + config.viewerOnly. feature() is build-time constant so
// the branch is dead-code-eliminated in non-KAIROS builds (same pattern
// as useUnseenDivider above).
const {
maybeLoadOlder
} = feature('KAIROS') ?
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
useAssistantHistory({
config: remoteSessionConfig,
setMessages,
scrollRef,
onPrepend: shiftDivider
}) : HISTORY_STUB;
// Compose useUnseenDivider's callbacks with the lazy-load trigger.
const composedOnScroll = useCallback((sticky: boolean, handle: ScrollBoxHandle) => {
lastUserScrollTsRef.current = Date.now();
if (sticky) {
onRepin();
} else {
onScrollAway(handle);
if (feature('KAIROS')) maybeLoadOlder(handle);
// Dismiss the companion bubble on scroll — it's absolute-positioned
// at bottom-right and covers transcript content. Scrolling = user is
// trying to read something under it.
if (feature('BUDDY')) {
setAppState(prev => prev.companionReaction === undefined ? prev : {
...prev,
companionReaction: undefined
});
}
}
}, [onRepin, onScrollAway, maybeLoadOlder, setAppState]);
// Deferred SessionStart hook messages — REPL renders immediately and
// hook messages are injected when they resolve. awaitPendingHooks()
// must be called before the first API call so the model sees hook context.
const awaitPendingHooks = useDeferredHookMessages(pendingHookMessages, setMessages);
// Deferred messages for the Messages component — renders at transition
// priority so the reconciler yields every 5ms, keeping input responsive
// while the expensive message processing pipeline runs.
const deferredMessages = useDeferredValue(messages);
const deferredBehind = messages.length - deferredMessages.length;
if (deferredBehind > 0) {
logForDebugging(`[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`);
}
// Frozen state for transcript mode - stores lengths instead of cloning arrays for memory efficiency
const [frozenTranscriptState, setFrozenTranscriptState] = useState<{
messagesLength: number;
streamingToolUsesLength: number;
} | null>(null);
// Initialize input with any early input that was captured before REPL was ready.
// Using lazy initialization ensures cursor offset is set correctly in PromptInput.
const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput());
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const insertTextRef = useRef<{
insert: (text: string) => void;
setInputWithCursor: (value: string, cursor: number) => void;
cursorOffset: number;
} | null>(null);
// Wrap setInputValue to co-locate suppression state updates.
// Both setState calls happen in the same synchronous context so React
// batches them into a single render, eliminating the extra render that
// the previous useEffect → setState pattern caused.
const setInputValue = useCallback((value: string) => {
if (trySuggestBgPRIntercept(inputValueRef.current, value)) return;
// In fullscreen mode, typing into an empty prompt re-pins scroll to
// bottom. Only fires on empty→non-empty so scrolling up to reference
// something while composing a message doesn't yank the view back on
// every keystroke. Restores the pre-fullscreen muscle memory of
// typing to snap back to the end of the conversation.
// Skipped if the user scrolled within the last 3s — they're actively
// reading, not lost. lastUserScrollTsRef starts at 0 so the first-
// ever keypress (no scroll yet) always repins.
if (inputValueRef.current === '' && value !== '' && Date.now() - lastUserScrollTsRef.current >= RECENT_SCROLL_REPIN_WINDOW_MS) {
repinScroll();
}
// Sync ref immediately (like setMessages) so callers that read
// inputValueRef before React commits — e.g. the auto-restore finally
// block's `=== ''` guard — see the fresh value, not the stale render.
inputValueRef.current = value;
setInputValueRaw(value);
setIsPromptInputActive(value.trim().length > 0);
}, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]);
// Schedule a timeout to stop suppressing dialogs after the user stops typing.
// Only manages the timeout — the immediate activation is handled by setInputValue above.
useEffect(() => {
if (inputValue.trim().length === 0) return;
const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false);
return () => clearTimeout(timer);
}, [inputValue]);
const [inputMode, setInputMode] = useState<PromptInputMode>('prompt');
const [stashedPrompt, setStashedPrompt] = useState<{
text: string;
cursorOffset: number;
pastedContents: Record<number, PastedContent>;
} | undefined>();
// Callback to filter commands based on CCR's available slash commands
const handleRemoteInit = useCallback((remoteSlashCommands: string[]) => {
const remoteCommandSet = new Set(remoteSlashCommands);
// Keep commands that CCR lists OR that are in the local-safe set
setLocalCommands(prev => prev.filter(cmd => remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd)));
}, [setLocalCommands]);
const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState<Set<string>>(new Set());
const hasInterruptibleToolInProgressRef = useRef(false);
// Remote session hook - manages WebSocket connection and message handling for --remote mode
const remoteSession = useRemoteSession({
config: remoteSessionConfig,
setMessages,
setIsLoading: setIsExternalLoading,
onInit: handleRemoteInit,
setToolUseConfirmQueue,
tools: combinedInitialTools,
setStreamingToolUses,
setStreamMode,
setInProgressToolUseIDs
});
// Direct connect hook - manages WebSocket to a claude server for `claude connect` mode
const directConnect = useDirectConnect({
config: directConnectConfig,
setMessages,
setIsLoading: setIsExternalLoading,
setToolUseConfirmQueue,
tools: combinedInitialTools
});
// SSH session hook - manages ssh child process for `claude ssh` mode.
// Same callback shape as useDirectConnect; only the transport under the
// hood differs (ChildProcess stdin/stdout vs WebSocket).
const sshRemote = useSSHSession({
session: sshSession,
setMessages,
setIsLoading: setIsExternalLoading,
setToolUseConfirmQueue,
tools: combinedInitialTools
});
// Use whichever remote mode is active
const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession;
const [pastedContents, setPastedContents] = useState<Record<number, PastedContent>>({});
const [submitCount, setSubmitCount] = useState(0);
// Ref instead of state to avoid triggering React re-renders on every
// streaming text_delta. The spinner reads this via its animation timer.
const responseLengthRef = useRef(0);
// API performance metrics ref for ant-only spinner display (TTFT/OTPS).
// Accumulates metrics from all API requests in a turn for P50 aggregation.
const apiMetricsRef = useRef<Array<{
ttftMs: number;
firstTokenTime: number;
lastTokenTime: number;
responseLengthBaseline: number;
// Tracks responseLengthRef at the time of the last content addition.
// Updated by both streaming deltas and subagent message content.
// lastTokenTime is also updated at the same time, so the OTPS
// denominator correctly includes subagent processing time.
endResponseLength: number;
}>>([]);
const setResponseLength = useCallback((f: (prev: number) => number) => {
const prev = responseLengthRef.current;
responseLengthRef.current = f(prev);
// When content is added (not a compaction reset), update the latest
// metrics entry so OTPS reflects all content generation activity.
// Updating lastTokenTime here ensures the denominator includes both
// streaming time AND subagent execution time, preventing inflation.
if (responseLengthRef.current > prev) {
const entries = apiMetricsRef.current;
if (entries.length > 0) {
const lastEntry = entries.at(-1)!;
lastEntry.lastTokenTime = Date.now();
lastEntry.endResponseLength = responseLengthRef.current;
}
}
}, []);
// Streaming text display: set state directly per delta (Ink's 16ms render
// throttle batches rapid updates). Cleared on message arrival (messages.ts)
// so displayedMessages switches from deferredMessages to messages atomically.
const [streamingText, setStreamingText] = useState<string | null>(null);
const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false;
const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug();
const onStreamingText = useCallback((f: (current: string | null) => string | null) => {
if (!showStreamingText) return;
setStreamingText(f);
}, [showStreamingText]);
// Hide the in-progress source line so text streams line-by-line, not
// char-by-char. lastIndexOf returns -1 when no newline, giving '' → null.
// Guard on showStreamingText so toggling reducedMotion mid-stream
// immediately hides the streaming preview.
const visibleStreamingText = streamingText && showStreamingText ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null : null;
const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0);
const [spinnerMessage, setSpinnerMessage] = useState<string | null>(null);
const [spinnerColor, setSpinnerColor] = useState<keyof Theme | null>(null);
const [spinnerShimmerColor, setSpinnerShimmerColor] = useState<keyof Theme | null>(null);
const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false);
const [messageSelectorPreselect, setMessageSelectorPreselect] = useState<UserMessage | undefined>(undefined);
const [showCostDialog, setShowCostDialog] = useState(false);
const [conversationId, setConversationId] = useState(randomUUID());
// Idle-return dialog: shown when user submits after a long idle gap
const [idleReturnPending, setIdleReturnPending] = useState<{
input: string;
idleMinutes: number;
} | null>(null);
const skipIdleCheckRef = useRef(false);
const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime);
lastQueryCompletionTimeRef.current = lastQueryCompletionTime;
// Aggregate tool result budget: per-conversation decision tracking.
// When the GrowthBook flag is on, query.ts enforces the budget; when
// off (undefined), enforcement is skipped entirely. Stale entries after
// /clear, rewind, or compact are harmless (tool_use_ids are UUIDs, stale
// keys are never looked up). Memory is bounded by total replacement count
// × ~2KB preview over the REPL lifetime — negligible.
//
// Lazy init via useState initializer — useRef(expr) evaluates expr on every
// render (React ignores it after first, but the computation still runs).
// For large resumed sessions, reconstruction does O(messages × blocks)
// work; we only want that once.
const [contentReplacementStateRef] = useState(() => ({
current: provisionContentReplacementState(initialMessages, initialContentReplacements)
}));
const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold);
const [vimMode, setVimMode] = useState<VimMode>('INSERT');
const [showBashesDialog, setShowBashesDialog] = useState<string | boolean>(false);
const [isSearchingHistory, setIsSearchingHistory] = useState(false);
const [isHelpOpen, setIsHelpOpen] = useState(false);
// showBashesDialog is REPL-level so it survives PromptInput unmounting.
// When ultraplan approval fires while the pill dialog is open, PromptInput
// unmounts (focusedInputDialog → 'ultraplan-choice') but this stays true;
// after accepting, PromptInput remounts into an empty "No tasks" dialog
// (the completed ultraplan task has been filtered out). Close it here.
useEffect(() => {
if (ultraplanPendingChoice && showBashesDialog) {
setShowBashesDialog(false);
}
}, [ultraplanPendingChoice, showBashesDialog]);
const isTerminalFocused = useTerminalFocus();
const terminalFocusRef = useRef(isTerminalFocused);
terminalFocusRef.current = isTerminalFocused;
const [theme] = useTheme();
// resetLoadingState runs twice per turn (onQueryImpl tail + onQuery finally).
// Without this guard, both calls pick a tip → two recordShownTip → two
// saveGlobalConfig writes back-to-back. Reset at submit in onSubmit.
const tipPickedThisTurnRef = React.useRef(false);
const pickNewSpinnerTip = useCallback(() => {
if (tipPickedThisTurnRef.current) return;
tipPickedThisTurnRef.current = true;
const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current);
for (const tool of extractBashToolsFromMessages(newMessages)) {
bashTools.current.add(tool);
}
bashToolsProcessedIdx.current = messagesRef.current.length;
void getTipToShowOnSpinner({
theme,
readFileState: readFileState.current,
bashTools: bashTools.current
}).then(async tip => {
if (tip) {
const content = await tip.content({
theme
});
setAppState(prev => ({
...prev,
spinnerTip: content
}));
recordShownTip(tip);
} else {
setAppState(prev => {
if (prev.spinnerTip === undefined) return prev;
return {
...prev,
spinnerTip: undefined
};
});
}
});
}, [setAppState, theme]);
// Resets UI loading state. Does NOT call onTurnComplete - that should be
// called explicitly only when a query turn actually completes.
const resetLoadingState = useCallback(() => {
// isLoading is now derived from queryGuard — no setter call needed.
// queryGuard.end() (onQuery finally) or cancelReservation() (executeUserInput
// finally) have already transitioned the guard to idle by the time this runs.
// External loading (remote/backgrounding) is reset separately by those hooks.
setIsExternalLoading(false);
setUserInputOnProcessing(undefined);
responseLengthRef.current = 0;
apiMetricsRef.current = [];
setStreamingText(null);
setStreamingToolUses([]);
setSpinnerMessage(null);
setSpinnerColor(null);
setSpinnerShimmerColor(null);
pickNewSpinnerTip();
endInteractionSpan();
// Speculative bash classifier checks are only valid for the current
// turn's commands — clear after each turn to avoid accumulating
// Promise chains for unconsumed checks (denied/aborted paths).
clearSpeculativeChecks();
}, [pickNewSpinnerTip]);
// Session backgrounding — hook is below, after getToolUseContext
const hasRunningTeammates = useMemo(() => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), [tasks]);
// Show deferred turn duration message once all swarm teammates finish
useEffect(() => {
if (!hasRunningTeammates && swarmStartTimeRef.current !== null) {
const totalMs = Date.now() - swarmStartTimeRef.current;
const deferredBudget = swarmBudgetInfoRef.current;
swarmStartTimeRef.current = null;
swarmBudgetInfoRef.current = undefined;
setMessages(prev => [...prev, createTurnDurationMessage(totalMs, deferredBudget,
// Count only what recordTranscript will persist — ephemeral
// progress ticks and non-ant attachments are filtered by
// isLoggableMessage and never reach disk. Using raw prev.length
// would make checkResumeConsistency report false delta<0 for
// every turn that ran a progress-emitting tool.
count(prev, isLoggableMessage))]);
}
}, [hasRunningTeammates, setMessages]);
// Show auto permissions warning when entering auto mode
// (either via Shift+Tab toggle or on startup). Debounced to avoid
// flashing when the user is cycling through modes quickly.
// Only shown 3 times total across sessions.
const safeYoloMessageShownRef = useRef(false);
useEffect(() => {
if (feature('TRANSCRIPT_CLASSIFIER')) {
if (toolPermissionContext.mode !== 'auto') {
safeYoloMessageShownRef.current = false;
return;
}
if (safeYoloMessageShownRef.current) return;
const config = getGlobalConfig();
const count = config.autoPermissionsNotificationCount ?? 0;
if (count >= 3) return;
const timer = setTimeout((ref, setMessages) => {
ref.current = true;
saveGlobalConfig(prev => {
const prevCount = prev.autoPermissionsNotificationCount ?? 0;
if (prevCount >= 3) return prev;
return {
...prev,
autoPermissionsNotificationCount: prevCount + 1
};
});
setMessages(prev => [...prev, createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning')]);
}, 800, safeYoloMessageShownRef, setMessages);
return () => clearTimeout(timer);
}
}, [toolPermissionContext.mode, setMessages]);
// If worktree creation was slow and sparse-checkout isn't configured,
// nudge the user toward settings.worktree.sparsePaths.
const worktreeTipShownRef = useRef(false);
useEffect(() => {
if (worktreeTipShownRef.current) return;
const wt = getCurrentWorktreeSession();
if (!wt?.creationDurationMs || wt.usedSparsePaths) return;
if (wt.creationDurationMs < 15_000) return;
worktreeTipShownRef.current = true;
const secs = Math.round(wt.creationDurationMs / 1000);
setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]);
}, [setMessages]);
// Hide spinner when the only in-progress tool is Sleep
const onlySleepToolActive = useMemo(() => {
const lastAssistant = messages.findLast(m => m.type === 'assistant');
if (lastAssistant?.type !== 'assistant') return false;
const inProgressToolUses = lastAssistant.message.content.filter(b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id));
return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME);
}, [messages, inProgressToolUseIDs]);
const {
onBeforeQuery: mrOnBeforeQuery,
onTurnComplete: mrOnTurnComplete,
render: mrRender
} = useMoreRight({
enabled: moreRightEnabled,
setMessages,
inputValue,
setInputValue,
setToolJSX
});
const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && (
// Show spinner during input processing, API call, while teammates are running,
// or while pending task notifications are queued (prevents spinner bounce between consecutive notifications)
isLoading || userInputOnProcessing || hasRunningTeammates ||
// Keep spinner visible while task notifications are queued for processing.
// Without this, the spinner briefly disappears between consecutive notifications
// (e.g., multiple background agents completing in rapid succession) because
// isLoading goes false momentarily between processing each one.
getCommandQueueLength() > 0) &&
// Hide spinner when waiting for leader to approve permission request
!pendingWorkerRequest && !onlySleepToolActive && (
// Hide spinner when streaming text is visible (the text IS the feedback),
// but keep it when isBriefOnly suppresses the streaming text display
!visibleStreamingText || isBriefOnly);
// Check if any permission or ask question prompt is currently visible
// This is used to prevent the survey from opening while prompts are active
const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0;
const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt);
const skillImprovementSurvey = useSkillImprovementSurvey(setMessages);
const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount);
// Wrap feedback survey handler to trigger auto-run /issue
const feedbackSurvey = useMemo(() => ({
...feedbackSurveyOriginal,
handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => {
// Reset the ref when a new survey response comes in
didAutoRunIssueRef.current = false;
const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected);
// Auto-run /issue for "bad" if transcript prompt wasn't shown
if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) {
setAutoRunIssueReason('feedback_survey_bad');
didAutoRunIssueRef.current = true;
}
}
}), [feedbackSurveyOriginal]);
// Post-compact survey: shown after compaction if feature gate is enabled
const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, {
enabled: !isRemoteSession
});
// Memory survey: shown when the assistant mentions memory and a memory file
// was read this conversation
const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, {
enabled: !isRemoteSession
});
// Frustration detection: show transcript sharing prompt after detecting frustrated messages
const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed');
// Initialize IDE integration
useIDEIntegration({
autoConnectIdeFlag,
ideToInstallExtension,
setDynamicMcpConfig,
setShowIdeOnboarding,
setIDEInstallationState: setIDEInstallationStatus
});
useFileHistorySnapshotInit(initialFileHistorySnapshots, fileHistory, fileHistoryState => setAppState(prev => ({
...prev,
fileHistory: fileHistoryState
})));
const resume = useCallback(async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => {
const resumeStart = performance.now();
try {
// Deserialize messages to properly clean up the conversation
// This filters unresolved tool uses and adds a synthetic assistant message if needed
const messages = deserializeMessages(log.messages);
// Match coordinator/normal mode to the resumed session
if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js');
/* eslint-enable @typescript-eslint/no-require-imports */
const warning = coordinatorModule.matchSessionMode(log.mode);
if (warning) {
// Re-derive agent definitions after mode switch so built-in agents
// reflect the new coordinator/normal mode
/* eslint-disable @typescript-eslint/no-require-imports */
const {
getAgentDefinitionsWithOverrides,
getActiveAgentsFromList
} = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js');
/* eslint-enable @typescript-eslint/no-require-imports */
getAgentDefinitionsWithOverrides.cache.clear?.();
const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd());
setAppState(prev => ({
...prev,
agentDefinitions: {
...freshAgentDefs,
allAgents: freshAgentDefs.allAgents,
activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents)
}
}));
messages.push(createSystemMessage(warning, 'warning'));
}
}
// Fire SessionEnd hooks for the current session before starting the
// resumed one, mirroring the /clear flow in conversation.ts.
const sessionEndTimeoutMs = getSessionEndHookTimeoutMs();
await executeSessionEndHooks('resume', {
getAppState: () => store.getState(),
setAppState,
signal: AbortSignal.timeout(sessionEndTimeoutMs),
timeoutMs: sessionEndTimeoutMs
});
// Process session start hooks for resume
const hookMessages = await processSessionStartHooks('resume', {
sessionId,
agentType: mainThreadAgentDefinition?.agentType,
model: mainLoopModel
});
// Append hook messages to the conversation
messages.push(...hookMessages);
// For forks, generate a new plan slug and copy the plan content so the
// original and forked sessions don't clobber each other's plan files.
// For regular resumes, reuse the original session's plan slug.
if (entrypoint === 'fork') {
void copyPlanForFork(log, asSessionId(sessionId));
} else {
void copyPlanForResume(log, asSessionId(sessionId));
}
// Restore file history and attribution state from the resumed conversation
restoreSessionStateFromLog(log, setAppState);
if (log.fileHistorySnapshots) {
void copyFileHistoryForResume(log);
}
// Restore agent setting from the resumed conversation
// Always reset to the new session's values (or clear if none),
// matching the standaloneAgentContext pattern below
const {
agentDefinition: restoredAgent
} = restoreAgentFromSession(log.agentSetting, initialMainThreadAgentDefinition, agentDefinitions);
setMainThreadAgentDefinition(restoredAgent);
setAppState(prev => ({
...prev,
agent: restoredAgent?.agentType
}));
// Restore standalone agent context from the resumed conversation
// Always reset to the new session's values (or clear if none)
setAppState(prev => ({
...prev,
standaloneAgentContext: computeStandaloneAgentContext(log.agentName, log.agentColor)
}));
void updateSessionName(log.agentName);
// Restore read file state from the message history
restoreReadFileState(messages, log.projectPath ?? getOriginalCwd());
// Clear any active loading state (no queryId since we're not in a query)
resetLoadingState();
setAbortController(null);
setConversationId(sessionId);
// Get target session's costs BEFORE saving current session
// (saveCurrentSessionCosts overwrites the config, so we need to read first)
const targetSessionCosts = getStoredSessionCosts(sessionId);
// Save current session's costs before switching to avoid losing accumulated costs
saveCurrentSessionCosts();
// Reset cost state for clean slate before restoring target session
resetCostState();
// Switch session (id + project dir atomically). fullPath may point to
// a different project (cross-worktree, /branch); null derives from
// current originalCwd.
switchSession(asSessionId(sessionId), log.fullPath ? dirname(log.fullPath) : null);
// Rename asciicast recording to match the resumed session ID
const {
renameRecordingForSession
} = await import('../utils/asciicast.js');
await renameRecordingForSession();
await resetSessionFilePointer();
// Clear then restore session metadata so it's re-appended on exit via
// reAppendSessionMetadata. clearSessionMetadata must be called first:
// restoreSessionMetadata only sets-if-truthy, so without the clear,
// a session without an agent name would inherit the previous session's
// cached name and write it to the wrong transcript on first message.
clearSessionMetadata();
restoreSessionMetadata(log);
// Resumed sessions shouldn't re-title from mid-conversation context
// (same reasoning as the useRef seed), and the previous session's
// Haiku title shouldn't carry over.
haikuTitleAttemptedRef.current = true;
setHaikuTitle(undefined);
// Exit any worktree a prior /resume entered, then cd into the one
// this session was in. Without the exit, resuming from worktree B
// to non-worktree C leaves cwd/currentWorktreeSession stale;
// resuming B→C where C is also a worktree fails entirely
// (getCurrentWorktreeSession guard blocks the switch).
//
// Skipped for /branch: forkLog doesn't carry worktreeSession, so
// this would kick the user out of a worktree they're still working
// in. Same fork skip as processResumedConversation for the adopt —
// fork materializes its own file via recordTranscript on REPL mount.
if (entrypoint !== 'fork') {
exitRestoredWorktree();
restoreWorktreeForResume(log.worktreeSession);
adoptResumedSessionFile();
void restoreRemoteAgentTasks({
abortController: new AbortController(),
getAppState: () => store.getState(),
setAppState
});
} else {
// Fork: same re-persist as /clear (conversation.ts). The clear
// above wiped currentSessionWorktree, forkLog doesn't carry it,
// and the process is still in the same worktree.
const ws = getCurrentWorktreeSession();
if (ws) saveWorktreeState(ws);
}
// Persist the current mode so future resumes know what mode this session was in
if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const {
saveMode
} = require('../utils/sessionStorage.js');
const {
isCoordinatorMode
} = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js');
/* eslint-enable @typescript-eslint/no-require-imports */
saveMode(isCoordinatorMode() ? 'coordinator' : 'normal');
}
// Restore target session's costs from the data we read earlier
if (targetSessionCosts) {
setCostStateForRestore(targetSessionCosts);
}
// Reconstruct replacement state for the resumed session. Runs after
// setSessionId so any NEW replacements post-resume write to the
// resumed session's tool-results dir. Gated on ref.current: the
// initial mount already read the feature flag, so we don't re-read
// it here (mid-session flag flips stay unobservable in both
// directions).
//
// Skipped for in-session /branch: the existing ref is already correct
// (branch preserves tool_use_ids), so there's no need to reconstruct.
// createFork() does write content-replacement entries to the forked
// JSONL with the fork's sessionId, so `claude -r {forkId}` also works.
if (contentReplacementStateRef.current && entrypoint !== 'fork') {
contentReplacementStateRef.current = reconstructContentReplacementState(messages, log.contentReplacements ?? []);
}
// Reset messages to the provided initial messages
// Use a callback to ensure we're not dependent on stale state
setMessages(() => messages);
// Clear any active tool JSX
setToolJSX(null);
// Clear input to ensure no residual state
setInputValue('');
logEvent('tengu_session_resumed', {
entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: true,
resume_duration_ms: Math.round(performance.now() - resumeStart)
});
} catch (error) {
logEvent('tengu_session_resumed', {
entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
success: false
});
throw error;
}
}, [resetLoadingState, setAppState]);
// Lazy init: useRef(createX()) would call createX on every render and
// discard the result. LRUCache construction inside FileStateCache is
// expensive (~170ms), so we use useState's lazy initializer to create
// it exactly once, then feed that stable reference into useRef.
const [initialReadFileState] = useState(() => createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE));
const readFileState = useRef(initialReadFileState);
const bashTools = useRef(new Set<string>());
const bashToolsProcessedIdx = useRef(0);
// Session-scoped skill discovery tracking (feeds was_discovered on
// tengu_skill_tool_invocation). Must persist across getToolUseContext
// rebuilds within a session: turn-0 discovery writes via processUserInput
// before onQuery builds its own context, and discovery on turn N must
// still attribute a SkillTool call on turn N+k. Cleared in clearConversation.
const discoveredSkillNamesRef = useRef(new Set<string>());
// Session-level dedup for nested_memory CLAUDE.md attachments.
// readFileState is a 100-entry LRU; once it evicts a CLAUDE.md path,
// the next discovery cycle re-injects it. Cleared in clearConversation.
const loadedNestedMemoryPathsRef = useRef(new Set<string>());
// Helper to restore read file state from messages (used for resume flows)
// This allows Claude to edit files that were read in previous sessions
const restoreReadFileState = useCallback((messages: MessageType[], cwd: string) => {
const extracted = extractReadFilesFromMessages(messages, cwd, READ_FILE_STATE_CACHE_SIZE);
readFileState.current = mergeFileStateCaches(readFileState.current, extracted);
for (const tool of extractBashToolsFromMessages(messages)) {
bashTools.current.add(tool);
}
}, []);
// Extract read file state from initialMessages on mount
// This handles CLI flag resume (--resume-session) and ResumeConversation screen
// where messages are passed as props rather than through the resume callback
useEffect(() => {
if (initialMessages && initialMessages.length > 0) {
restoreReadFileState(initialMessages, getOriginalCwd());
void restoreRemoteAgentTasks({
abortController: new AbortController(),
getAppState: () => store.getState(),
setAppState
});
}
// Only run on mount - initialMessages shouldn't change during component lifetime
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
status: apiKeyStatus,
reverify
} = useApiKeyVerification();
// Auto-run /issue state
const [autoRunIssueReason, setAutoRunIssueReason] = useState<AutoRunIssueReason | null>(null);
// Ref to track if autoRunIssue was triggered this survey cycle,
// so we can suppress the [1] follow-up prompt even after
// autoRunIssueReason is cleared.
const didAutoRunIssueRef = useRef(false);
// State for exit feedback flow
const [exitFlow, setExitFlow] = useState<React.ReactNode>(null);
const [isExiting, setIsExiting] = useState(false);
// Calculate if cost dialog should be shown
const showingCostDialog = !isLoading && showCostDialog;
// Determine which dialog should have focus (if any)
// Permission and interactive dialogs can show even when toolJSX is set,
// as long as shouldContinueAnimation is true. This prevents deadlocks when
// agents set background hints while waiting for user interaction.
function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'model-switch' | 'undercover-callout' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined {
// Exit states always take precedence
if (isExiting || exitFlow) return undefined;
// High priority dialogs (always show regardless of typing)
if (isMessageSelectorVisible) return 'message-selector';
// Suppress interrupt dialogs while user is actively typing
if (isPromptInputActive) return undefined;
if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission';
// Permission/interactive dialogs (show unless blocked by toolJSX)
const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation;
if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission';
if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt';
// Worker sandbox permission prompts (network access) from swarm workers
if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission';
if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation';
if (allowDialogsWithAnimation && showingCostDialog) return 'cost';
if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return';
if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) return 'ultraplan-choice';
if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) return 'ultraplan-launch';
// Onboarding dialogs (special conditions)
if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding';
// Model switch callout (ant-only, eliminated from external builds)
if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch';
// Undercover auto-enable explainer (ant-only, eliminated from external builds)
if ("external" === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout';
// Effort callout (shown once for Opus 4.6 users when effort is enabled)
if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout';
// Remote callout (shown once before first bridge enable)
if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout';
// LSP plugin recommendation (lowest priority - non-blocking suggestion)
if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation';
// Plugin hint from CLI/SDK stderr (same priority band as LSP rec)
if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint';
// Desktop app upsell (max 3 launches, lowest priority)
if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell';
return undefined;
}
const focusedInputDialog = getFocusedInputDialog();
// True when permission prompts exist but are hidden because the user is typing
const hasSuppressedDialogs = isPromptInputActive && (sandboxPermissionRequestQueue[0] || toolUseConfirmQueue[0] || promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || showingCostDialog);
// Keep ref in sync so timer callbacks can read the current value
focusedInputDialogRef.current = focusedInputDialog;
// Immediately capture pause/resume when focusedInputDialog changes
// This ensures accurate timing even under high system load, rather than
// relying on the 100ms polling interval to detect state changes
useEffect(() => {
if (!isLoading) return;
const isPaused = focusedInputDialog === 'tool-permission';
const now = Date.now();
if (isPaused && pauseStartTimeRef.current === null) {
// Just entered pause state - record the exact moment
pauseStartTimeRef.current = now;
} else if (!isPaused && pauseStartTimeRef.current !== null) {
// Just exited pause state - accumulate paused time immediately
totalPausedMsRef.current += now - pauseStartTimeRef.current;
pauseStartTimeRef.current = null;
}
}, [focusedInputDialog, isLoading]);
// Re-pin scroll to bottom whenever the permission overlay appears or
// dismisses. Overlay now renders below messages inside the same
// ScrollBox (no remount), so we need an explicit scrollToBottom for:
// - appear: user may have been scrolled up (sticky broken) — the
// dialog is blocking and must be visible
// - dismiss: user may have scrolled up to read context during the
// overlay, and onScroll was suppressed so the pill state is stale
// useLayoutEffect so the re-pin commits before the Ink frame renders —
// no 1-frame flash of the wrong scroll position.
const prevDialogRef = useRef(focusedInputDialog);
useLayoutEffect(() => {
const was = prevDialogRef.current === 'tool-permission';
const now = focusedInputDialog === 'tool-permission';
if (was !== now) repinScroll();
prevDialogRef.current = focusedInputDialog;
}, [focusedInputDialog, repinScroll]);
function onCancel() {
if (focusedInputDialog === 'elicitation') {
// Elicitation dialog handles its own Escape, and closing it shouldn't affect any loading state.
return;
}
logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`);
// Pause proactive mode so the user gets control back.
// It will resume when they submit their next input (see onSubmit).
if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.pauseProactive();
}
queryGuard.forceEnd();
skipIdleCheckRef.current = false;
// Preserve partially-streamed text so the user can read what was
// generated before pressing Esc. Pushed before resetLoadingState clears
// streamingText, and before query.ts yields the async interrupt marker,
// giving final order [user, partial-assistant, [Request interrupted by user]].
if (streamingText?.trim()) {
setMessages(prev => [...prev, createAssistantMessage({
content: streamingText
})]);
}
resetLoadingState();
// Clear any active token budget so the backstop doesn't fire on
// a stale budget if the query generator hasn't exited yet.
if (feature('TOKEN_BUDGET')) {
snapshotOutputTokensForTurn(null);
}
if (focusedInputDialog === 'tool-permission') {
// Tool use confirm handles the abort signal itself
toolUseConfirmQueue[0]?.onAbort();
setToolUseConfirmQueue([]);
} else if (focusedInputDialog === 'prompt') {
// Reject all pending prompts and clear the queue
for (const item of promptQueue) {
item.reject(new Error('Prompt cancelled by user'));
}
setPromptQueue([]);
abortController?.abort('user-cancel');
} else if (activeRemote.isRemoteMode) {
// Remote mode: send interrupt signal to CCR
activeRemote.cancelRequest();
} else {
abortController?.abort('user-cancel');
}
// Clear the controller so subsequent Escape presses don't see a stale
// aborted signal. Without this, canCancelRunningTask is false (signal
// defined but .aborted === true), so isActive becomes false if no other
// activating conditions hold — leaving the Escape keybinding inactive.
setAbortController(null);
// forceEnd() skips the finally path — fire directly (aborted=true).
void mrOnTurnComplete(messagesRef.current, true);
}
// Function to handle queued command when canceling a permission request
const handleQueuedCommandOnCancel = useCallback(() => {
const result = popAllEditable(inputValue, 0);
if (!result) return;
setInputValue(result.text);
setInputMode('prompt');
// Restore images from queued commands to pastedContents
if (result.images.length > 0) {
setPastedContents(prev => {
const newContents = {
...prev
};
for (const image of result.images) {
newContents[image.id] = image;
}
return newContents;
});
}
}, [setInputValue, setInputMode, inputValue, setPastedContents]);
// CancelRequestHandler props - rendered inside KeybindingSetup
const cancelRequestProps = {
setToolUseConfirmQueue,
onCancel,
onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]),
isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog,
screen,
abortSignal: abortController?.signal,
popCommandFromQueue: handleQueuedCommandOnCancel,
vimMode,
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
isSearchingHistory,
isHelpOpen,
inputMode,
inputValue,
streamMode
};
useEffect(() => {
const totalCost = getTotalCost();
if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) {
logEvent('tengu_cost_threshold_reached', {});
// Mark as shown even if the dialog won't render (no console billing
// access). Otherwise this effect re-fires on every message change for
// the rest of the session — 200k+ spurious events observed.
setHaveShownCostDialog(true);
if (hasConsoleBillingAccess()) {
setShowCostDialog(true);
}
}
}, [messages, showCostDialog, haveShownCostDialog]);
const sandboxAskCallback: SandboxAskCallback = useCallback(async (hostPattern: NetworkHostPattern) => {
// If running as a swarm worker, forward the request to the leader via mailbox
if (isAgentSwarmsEnabled() && isSwarmWorker()) {
const requestId = generateSandboxRequestId();
// Send the request to the leader via mailbox
const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId);
return new Promise(resolveShouldAllowHost => {
if (!sent) {
// If we couldn't send via mailbox, fall back to local handling
setSandboxPermissionRequestQueue(prev => [...prev, {
hostPattern,
resolvePromise: resolveShouldAllowHost
}]);
return;
}
// Register the callback for when the leader responds
registerSandboxPermissionCallback({
requestId,
host: hostPattern.host,
resolve: resolveShouldAllowHost
});
// Update AppState to show pending indicator
setAppState(prev => ({
...prev,
pendingSandboxRequest: {
requestId,
host: hostPattern.host
}
}));
});
}
// Normal flow for non-workers: show local UI and optionally race
// against the REPL bridge (Remote Control) if connected.
return new Promise(resolveShouldAllowHost => {
let resolved = false;
function resolveOnce(allow: boolean): void {
if (resolved) return;
resolved = true;
resolveShouldAllowHost(allow);
}
// Queue the local sandbox permission dialog
setSandboxPermissionRequestQueue(prev => [...prev, {
hostPattern,
resolvePromise: resolveOnce
}]);
// When the REPL bridge is connected, also forward the sandbox
// permission request as a can_use_tool control_request so the
// remote user (e.g. on claude.ai) can approve it too.
if (feature('BRIDGE_MODE')) {
const bridgeCallbacks = store.getState().replBridgePermissionCallbacks;
if (bridgeCallbacks) {
const bridgeRequestId = randomUUID();
bridgeCallbacks.sendRequest(bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, {
host: hostPattern.host
}, randomUUID(), `Allow network connection to ${hostPattern.host}?`);
const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => {
unsubscribe();
const allow = response.behavior === 'allow';
// Resolve ALL pending requests for the same host, not just
// this one — mirrors the local dialog handler pattern.
setSandboxPermissionRequestQueue(queue => {
queue.filter(item => item.hostPattern.host === hostPattern.host).forEach(item => item.resolvePromise(allow));
return queue.filter(item => item.hostPattern.host !== hostPattern.host);
});
// Clean up all sibling bridge subscriptions for this host
// (other concurrent same-host requests) before deleting.
const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host);
if (siblingCleanups) {
for (const fn of siblingCleanups) {
fn();
}
sandboxBridgeCleanupRef.current.delete(hostPattern.host);
}
});
// Register cleanup so the local dialog handler can cancel
// the remote prompt and unsubscribe when the local user
// responds first.
const cleanup = () => {
unsubscribe();
bridgeCallbacks.cancelRequest(bridgeRequestId);
};
const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? [];
existing.push(cleanup);
sandboxBridgeCleanupRef.current.set(hostPattern.host, existing);
}
}
});
}, [setAppState, store]);
// #34044: if user explicitly set sandbox.enabled=true but deps are missing,
// isSandboxingEnabled() returns false silently. Surface the reason once at
// mount so users know their security config isn't being enforced. Full
// reason goes to debug log; notification points to /sandbox for details.
// addNotification is stable (useCallback) so the effect fires once.
useEffect(() => {
const reason = SandboxManager.getSandboxUnavailableReason();
if (!reason) return;
if (SandboxManager.isSandboxRequired()) {
process.stderr.write(`\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`);
gracefulShutdownSync(1, 'other');
return;
}
logForDebugging(`sandbox disabled: ${reason}`, {
level: 'warn'
});
addNotification({
key: 'sandbox-unavailable',
jsx: <>
<Text color="warning">sandbox disabled</Text>
<Text dimColor> · /sandbox</Text>
</>,
priority: 'medium'
});
}, [addNotification]);
if (SandboxManager.isSandboxingEnabled()) {
// If sandboxing is enabled (setting.sandbox is defined, initialise the manager)
SandboxManager.initialize(sandboxAskCallback).catch(err => {
// Initialization/validation failed - display error and exit
process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`);
gracefulShutdownSync(1, 'other');
});
}
const setToolPermissionContext = useCallback((context: ToolPermissionContext, options?: {
preserveMode?: boolean;
}) => {
setAppState(prev => ({
...prev,
toolPermissionContext: {
...context,
// Preserve the coordinator's mode only when explicitly requested.
// Workers' getAppState() returns a transformed context with mode
// 'acceptEdits' that must not leak into the coordinator's actual
// state via permission-rule updates — those call sites pass
// { preserveMode: true }. User-initiated mode changes (e.g.,
// selecting "allow all edits") must NOT be overridden.
mode: options?.preserveMode ? prev.toolPermissionContext.mode : context.mode
}
}));
// When permission context changes, recheck all queued items
// This handles the case where approving item1 with "don't ask again"
// should auto-approve other queued items that now match the updated rules
setImmediate(setToolUseConfirmQueue => {
// Use setToolUseConfirmQueue callback to get current queue state
// instead of capturing it in the closure, to avoid stale closure issues
setToolUseConfirmQueue(currentQueue => {
currentQueue.forEach(item => {
void item.recheckPermission();
});
return currentQueue;
});
}, setToolUseConfirmQueue);
}, [setAppState, setToolUseConfirmQueue]);
// Register the leader's setToolPermissionContext for in-process teammates
useEffect(() => {
registerLeaderSetToolPermissionContext(setToolPermissionContext);
return () => unregisterLeaderSetToolPermissionContext();
}, [setToolPermissionContext]);
const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext);
const requestPrompt = useCallback((title: string, toolInputSummary?: string | null) => (request: PromptRequest): Promise<PromptResponse> => new Promise<PromptResponse>((resolve, reject) => {
setPromptQueue(prev => [...prev, {
request,
title,
toolInputSummary,
resolve,
reject
}]);
}), []);
const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string): ProcessUserInputContext => {
// Read mutable values fresh from the store rather than closure-capturing
// useAppState() snapshots. Same values today (closure is refreshed by the
// render between turns); decouples freshness from React's render cycle for
// a future headless conversation loop. Same pattern refreshTools() uses.
const s = store.getState();
// Compute tools fresh from store.getState() rather than the closure-
// captured `tools`. useManageMCPConnections populates appState.mcp
// async as servers connect — the store may have newer MCP state than
// the closure captured at render time. Also doubles as refreshTools()
// for mid-query tool list updates.
const computeTools = () => {
const state = store.getState();
const assembled = assembleToolPool(state.toolPermissionContext, state.mcp.tools);
const merged = mergeAndFilterTools(combinedInitialTools, assembled, state.toolPermissionContext.mode);
if (!mainThreadAgentDefinition) return merged;
return resolveAgentTools(mainThreadAgentDefinition, merged, false, true).resolvedTools;
};
return {
abortController,
options: {
commands,
tools: computeTools(),
debug,
verbose: s.verbose,
mainLoopModel,
thinkingConfig: s.thinkingEnabled !== false ? thinkingConfig : {
type: 'disabled'
},
// Merge fresh from store rather than closing over useMergedClients'
// memoized output. initialMcpClients is a prop (session-constant).
mcpClients: mergeClients(initialMcpClients, s.mcp.clients),
mcpResources: s.mcp.resources,
ideInstallationStatus: ideInstallationStatus,
isNonInteractiveSession: false,
dynamicMcpConfig,
theme,
agentDefinitions: allowedAgentTypes ? {
...s.agentDefinitions,
allowedAgentTypes
} : s.agentDefinitions,
customSystemPrompt,
appendSystemPrompt,
refreshTools: computeTools
},
getAppState: () => store.getState(),
setAppState,
messages,
setMessages,
updateFileHistoryState(updater: (prev: FileHistoryState) => FileHistoryState) {
// Perf: skip the setState when the updater returns the same reference
// (e.g. fileHistoryTrackEdit returns `state` when the file is already
// tracked). Otherwise every no-op call would notify all store listeners.
setAppState(prev => {
const updated = updater(prev.fileHistory);
if (updated === prev.fileHistory) return prev;
return {
...prev,
fileHistory: updated
};
});
},
updateAttributionState(updater: (prev: AttributionState) => AttributionState) {
setAppState(prev => {
const updated = updater(prev.attribution);
if (updated === prev.attribution) return prev;
return {
...prev,
attribution: updated
};
});
},
openMessageSelector: () => {
if (!disabled) {
setIsMessageSelectorVisible(true);
}
},
onChangeAPIKey: reverify,
readFileState: readFileState.current,
setToolJSX,
addNotification,
appendSystemMessage: msg => setMessages(prev => [...prev, msg]),
sendOSNotification: opts => {
void sendNotification(opts, terminal);
},
onChangeDynamicMcpConfig,
onInstallIDEExtension: setIDEToInstallExtension,
nestedMemoryAttachmentTriggers: new Set<string>(),
loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current,
dynamicSkillDirTriggers: new Set<string>(),
discoveredSkillNames: discoveredSkillNamesRef.current,
setResponseLength,
pushApiMetricsEntry: "external" === 'ant' ? (ttftMs: number) => {
const now = Date.now();
const baseline = responseLengthRef.current;
apiMetricsRef.current.push({
ttftMs,
firstTokenTime: now,
lastTokenTime: now,
responseLengthBaseline: baseline,
endResponseLength: baseline
});
} : undefined,
setStreamMode,
onCompactProgress: event => {
switch (event.type) {
case 'hooks_start':
setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER');
setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER');
setSpinnerMessage(event.hookType === 'pre_compact' ? 'Running PreCompact hooks\u2026' : event.hookType === 'post_compact' ? 'Running PostCompact hooks\u2026' : 'Running SessionStart hooks\u2026');
break;
case 'compact_start':
setSpinnerMessage('Compacting conversation');
break;
case 'compact_end':
setSpinnerMessage(null);
setSpinnerColor(null);
setSpinnerShimmerColor(null);
break;
}
},
setInProgressToolUseIDs,
setHasInterruptibleToolInProgress: (v: boolean) => {
hasInterruptibleToolInProgressRef.current = v;
},
resume,
setConversationId,
requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined,
contentReplacementState: contentReplacementStateRef.current
};
}, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]);
// Session backgrounding (Ctrl+B to background/foreground)
const handleBackgroundQuery = useCallback(() => {
// Stop the foreground query so the background one takes over
abortController?.abort('background');
// Aborting subagents may produce task-completed notifications.
// Clear task notifications so the queue processor doesn't immediately
// start a new foreground query; forward them to the background session.
const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification');
void (async () => {
const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel);
const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]);
const systemPrompt = buildEffectiveSystemPrompt({
mainThreadAgentDefinition,
toolUseContext,
customSystemPrompt,
defaultSystemPrompt,
appendSystemPrompt
});
toolUseContext.renderedSystemPrompt = systemPrompt;
const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []);
const notificationMessages = notificationAttachments.map(createAttachmentMessage);
// Deduplicate: if the query loop already yielded a notification into
// messagesRef before we removed it from the queue, skip duplicates.
// We use prompt text for dedup because source_uuid is not set on
// task-notification QueuedCommands (enqueuePendingNotification callers
// don't pass uuid), so it would always be undefined.
const existingPrompts = new Set<string>();
for (const m of messagesRef.current) {
if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') {
existingPrompts.add(m.attachment.prompt);
}
}
const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt)));
startBackgroundSession({
messages: [...messagesRef.current, ...uniqueNotifications],
queryParams: {
systemPrompt,
userContext,
systemContext,
canUseTool,
toolUseContext,
querySource: getQuerySourceForREPL()
},
description: terminalTitle,
setAppState,
agentDefinition: mainThreadAgentDefinition
});
})();
}, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]);
const {
handleBackgroundSession
} = useSessionBackgrounding({
setMessages,
setIsLoading: setIsExternalLoading,
resetLoadingState,
setAbortController,
onBackgroundQuery: handleBackgroundQuery
});
const onQueryEvent = useCallback((event: Parameters<typeof handleMessageFromStream>[0]) => {
handleMessageFromStream(event, newMessage => {
if (isCompactBoundaryMessage(newMessage)) {
// Fullscreen: keep pre-compact messages for scrollback. query.ts
// slices at the boundary for API calls, Messages.tsx skips the
// boundary filter in fullscreen, and useLogMessages treats this
// as an incremental append (first uuid unchanged). Cap at one
// compact-interval of scrollback — normalizeMessages/applyGrouping
// are O(n) per render, so drop everything before the previous
// boundary to keep n bounded across multi-day sessions.
if (isFullscreenEnvEnabled()) {
setMessages(old => [...getMessagesAfterCompactBoundary(old, {
includeSnipped: true
}), newMessage]);
} else {
setMessages(() => [newMessage]);
}
// Bump conversationId so Messages.tsx row keys change and
// stale memoized rows remount with post-compact content.
setConversationId(randomUUID());
// Compaction succeeded — clear the context-blocked flag so ticks resume
if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.setContextBlocked(false);
}
} else if (newMessage.type === 'progress' && isEphemeralToolProgress(newMessage.data.type)) {
// Replace the previous ephemeral progress tick for the same tool
// call instead of appending. Sleep/Bash emit a tick per second and
// only the last one is rendered; appending blows up the messages
// array (13k+ observed) and the transcript (120MB of sleep_progress
// lines). useLogMessages tracks length, so same-length replacement
// also skips the transcript write.
// agent_progress / hook_progress / skill_progress are NOT ephemeral
// — each carries distinct state the UI needs (e.g. subagent tool
// history). Replacing those leaves the AgentTool UI stuck at
// "Initializing…" because it renders the full progress trail.
setMessages(oldMessages => {
const last = oldMessages.at(-1);
if (last?.type === 'progress' && last.parentToolUseID === newMessage.parentToolUseID && last.data.type === newMessage.data.type) {
const copy = oldMessages.slice();
copy[copy.length - 1] = newMessage;
return copy;
}
return [...oldMessages, newMessage];
});
} else {
setMessages(oldMessages => [...oldMessages, newMessage]);
}
// Block ticks on API errors to prevent tick → error → tick
// runaway loops (e.g., auth failure, rate limit, blocking limit).
// Cleared on compact boundary (above) or successful response (below).
if (feature('PROACTIVE') || feature('KAIROS')) {
if (newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) {
proactiveModule?.setContextBlocked(true);
} else if (newMessage.type === 'assistant') {
proactiveModule?.setContextBlocked(false);
}
}
}, newContent => {
// setResponseLength handles updating both responseLengthRef (for
// spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime
// for OTPS). No separate metrics update needed here.
setResponseLength(length => length + newContent.length);
}, setStreamMode, setStreamingToolUses, tombstonedMessage => {
setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage));
void removeTranscriptMessage(tombstonedMessage.uuid);
}, setStreamingThinking, metrics => {
const now = Date.now();
const baseline = responseLengthRef.current;
apiMetricsRef.current.push({
...metrics,
firstTokenTime: now,
lastTokenTime: now,
responseLengthBaseline: baseline,
endResponseLength: baseline
});
}, onStreamingText);
}, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]);
const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue) => {
// Prepare IDE integration for new prompt. Read mcpClients fresh from
// store — useManageMCPConnections may have populated it since the
// render that captured this closure (same pattern as computeTools).
if (shouldQuery) {
const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients);
void diagnosticTracker.handleQueryStart(freshClients);
const ideClient = getConnectedIdeClient(freshClients);
if (ideClient) {
void closeOpenDiffs(ideClient);
}
}
// Mark onboarding as complete when any user message is sent to Claude
void maybeMarkProjectOnboardingComplete();
// Extract a session title from the first real user message. One-shot
// via ref (was tengu_birch_mist experiment: first-message-only to save
// Haiku calls). The ref replaces the old `messages.length <= 1` check,
// which was broken by SessionStart hook messages (prepended via
// useDeferredHookMessages) and attachment messages (appended by
// processTextPrompt) — both pushed length past 1 on turn one, so the
// title silently fell through to the "Claude Code" default.
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content) : null;
// Skip synthetic breadcrumbs — slash-command output, prompt-skill
// expansions (/commit → <command-message>), local-command headers
// (/help → <command-name>), and bash-mode (!cmd → <bash-input>).
// None of these are the user's topic; wait for real prose.
if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`)) {
haikuTitleAttemptedRef.current = true;
void generateSessionTitle(text, new AbortController().signal).then(title => {
if (title) setHaikuTitle(title);else haikuTitleAttemptedRef.current = false;
}, () => {
haikuTitleAttemptedRef.current = false;
});
}
}
// Apply slash-command-scoped allowedTools (from skill frontmatter) to the
// store once per turn. This also covers the reset: the next non-skill turn
// passes [] and clears it. Must run before the !shouldQuery gate: forked
// commands (executeForkedSlashCommand) return shouldQuery=false, and
// createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so
// stale skill tools would otherwise leak into forked agent permissions.
// Previously this write was hidden inside getToolUseContext's getAppState
// (~85 calls/turn); hoisting it here makes getAppState a pure read and stops
// ephemeral contexts (permission dialog, BackgroundTasksDialog) from
// accidentally clearing it mid-turn.
store.setState(prev => {
const cur = prev.toolPermissionContext.alwaysAllowRules.command;
if (cur === additionalAllowedTools || cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) {
return prev;
}
return {
...prev,
toolPermissionContext: {
...prev.toolPermissionContext,
alwaysAllowRules: {
...prev.toolPermissionContext.alwaysAllowRules,
command: additionalAllowedTools
}
}
};
});
// The last message is an assistant message if the user input was a bash command,
// or if the user input was an invalid slash command.
if (!shouldQuery) {
// Manual /compact sets messages directly (shouldQuery=false) bypassing
// handleMessageFromStream. Clear context-blocked if a compact boundary
// is present so proactive ticks resume after compaction.
if (newMessages.some(isCompactBoundaryMessage)) {
// Bump conversationId so Messages.tsx row keys change and
// stale memoized rows remount with post-compact content.
setConversationId(randomUUID());
if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.setContextBlocked(false);
}
}
resetLoadingState();
setAbortController(null);
return;
}
const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam);
// getToolUseContext reads tools/mcpClients fresh from store.getState()
// (via computeTools/mergeClients). Use those rather than the closure-
// captured `tools`/`mcpClients` — useManageMCPConnections may have
// flushed new MCP state between the render that captured this closure
// and now. Turn 1 via processInitialMessage is the main beneficiary.
const {
tools: freshTools,
mcpClients: freshMcpClients
} = toolUseContext.options;
// Scope the skill's effort override to this turn's context only —
// wrapping getAppState keeps the override out of the global store so
// background agents and UI subscribers (Spinner, LogoV2) never see it.
if (effort !== undefined) {
const previousGetAppState = toolUseContext.getAppState;
toolUseContext.getAppState = () => ({
...previousGetAppState(),
effortValue: effort
});
}
queryCheckpoint('query_context_loading_start');
const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
// IMPORTANT: do this after setMessages() above, to avoid UI jank
checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState),
// Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]);
const userContext = {
...baseUserContext,
...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined),
...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? {
terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.'
} : {})
};
queryCheckpoint('query_context_loading_end');
const systemPrompt = buildEffectiveSystemPrompt({
mainThreadAgentDefinition,
toolUseContext,
customSystemPrompt,
defaultSystemPrompt,
appendSystemPrompt
});
toolUseContext.renderedSystemPrompt = systemPrompt;
queryCheckpoint('query_query_start');
resetTurnHookDuration();
resetTurnToolDuration();
resetTurnClassifierDuration();
for await (const event of query({
messages: messagesIncludingNewMessages,
systemPrompt,
userContext,
systemContext,
canUseTool,
toolUseContext,
querySource: getQuerySourceForREPL()
})) {
onQueryEvent(event);
}
if (feature('BUDDY')) {
void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : {
...prev,
companionReaction: reaction
}));
}
queryCheckpoint('query_end');
// Capture ant-only API metrics before resetLoadingState clears the ref.
// For multi-request turns (tool use loops), compute P50 across all requests.
if ("external" === 'ant' && apiMetricsRef.current.length > 0) {
const entries = apiMetricsRef.current;
const ttfts = entries.map(e => e.ttftMs);
// Compute per-request OTPS using only active streaming time and
// streaming-only content. endResponseLength tracks content added by
// streaming deltas only, excluding subagent/compaction inflation.
const otpsValues = entries.map(e => {
const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4);
const samplingMs = e.lastTokenTime - e.firstTokenTime;
return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0;
});
const isMultiRequest = entries.length > 1;
const hookMs = getTurnHookDurationMs();
const hookCount = getTurnHookCount();
const toolMs = getTurnToolDurationMs();
const toolCount = getTurnToolCount();
const classifierMs = getTurnClassifierDurationMs();
const classifierCount = getTurnClassifierCount();
const turnMs = Date.now() - loadingStartTimeRef.current;
setMessages(prev => [...prev, createApiMetricsMessage({
ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!,
otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!,
isP50: isMultiRequest,
hookDurationMs: hookMs > 0 ? hookMs : undefined,
hookCount: hookCount > 0 ? hookCount : undefined,
turnDurationMs: turnMs > 0 ? turnMs : undefined,
toolDurationMs: toolMs > 0 ? toolMs : undefined,
toolCount: toolCount > 0 ? toolCount : undefined,
classifierDurationMs: classifierMs > 0 ? classifierMs : undefined,
classifierCount: classifierCount > 0 ? classifierCount : undefined,
configWriteCount: getGlobalConfigWriteCount()
})]);
}
resetLoadingState();
// Log query profiling report if enabled
logQueryProfileReport();
// Signal that a query turn has completed successfully
await onTurnComplete?.(messagesRef.current);
}, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]);
const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void> => {
// If this is a teammate, mark them as active when starting a turn
if (isAgentSwarmsEnabled()) {
const teamName = getTeamName();
const agentName = getAgentName();
if (teamName && agentName) {
// Fire and forget - turn starts immediately, write happens in background
void setMemberActive(teamName, agentName, true);
}
}
// Concurrent guard via state machine. tryStart() atomically checks
// and transitions idle→running, returning the generation number.
// Returns null if already running — no separate check-then-set.
const thisGeneration = queryGuard.tryStart();
if (thisGeneration === null) {
logEvent('tengu_concurrent_onquery_detected', {});
// Extract and enqueue user message text, skipping meta messages
// (e.g. expanded skill content, tick prompts) that should not be
// replayed as user-visible text.
newMessages.filter((m): m is UserMessage => m.type === 'user' && !m.isMeta).map(_ => getContentText(_.message.content)).filter(_ => _ !== null).forEach((msg, i) => {
enqueue({
value: msg,
mode: 'prompt'
});
if (i === 0) {
logEvent('tengu_concurrent_onquery_enqueued', {});
}
});
return;
}
try {
// isLoading is derived from queryGuard — tryStart() above already
// transitioned dispatching→running, so no setter call needed here.
resetTimingRefs();
setMessages(oldMessages => [...oldMessages, ...newMessages]);
responseLengthRef.current = 0;
if (feature('TOKEN_BUDGET')) {
const parsedBudget = input ? parseTokenBudget(input) : null;
snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget());
}
apiMetricsRef.current = [];
setStreamingToolUses([]);
setStreamingText(null);
// messagesRef is updated synchronously by the setMessages wrapper
// above, so it already includes newMessages from the append at the
// top of this try block. No reconstruction needed, no waiting for
// React's scheduler (previously cost 20-56ms per prompt; the 56ms
// case was a GC pause caught during the await).
const latestMessages = messagesRef.current;
if (input) {
await mrOnBeforeQuery(input, latestMessages, newMessages.length);
}
// Pass full conversation history to callback
if (onBeforeQueryCallback && input) {
const shouldProceed = await onBeforeQueryCallback(input, latestMessages);
if (!shouldProceed) {
return;
}
}
await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, effort);
} finally {
// queryGuard.end() atomically checks generation and transitions
// running→idle. Returns false if a newer query owns the guard
// (cancel+resubmit race where the stale finally fires as a microtask).
if (queryGuard.e