// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import { feature } from 'bun:bundle' import { spawnSync } from 'child_process' import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens, } from '../bootstrap/state.js' import { parseTokenBudget } from '../utils/tokenBudget.js' import { count } from '../utils/array.js' import { dirname, join } from 'path' import { tmpdir } from 'os' import figures from 'figures' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler import { useInput } from '../ink.js' import { useSearchInput } from '../hooks/useSearchInput.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js' import type { JumpHandle } from '../components/VirtualMessageList.js' import { renderMessagesToPlainText } from '../utils/exportRenderer.js' import { openFileInExternalEditor } from '../utils/editor.js' import { writeFile } from 'fs/promises' import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus, } from '../ink.js' import type { TabStatusKind } from '../ink/hooks/use-tab-status.js' import { CostThresholdDialog } from '../components/CostThresholdDialog.js' import { IdleReturnDialog } from '../components/IdleReturnDialog.js' import * as React from 'react' import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, useLayoutEffect, type RefObject, } from 'react' import { useNotifications } from '../context/notifications.js' import { sendNotification } from '../services/notifier.js' import { startPreventSleep, stopPreventSleep, } from '../services/preventSleep.js' import { useTerminalNotification } from '../ink/useTerminalNotification.js' import { hasCursorUpViewportYankBug } from '../ink/terminal.js' import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE, } from '../utils/fileStateCache.js' import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration, } from '../bootstrap/state.js' import { asSessionId, asAgentId } from '../types/ids.js' import { logForDebugging } from '../utils/debug.js' import { QueryGuard } from '../utils/QueryGuard.js' import { useGoalAutoContinue } from '../hooks/useGoalAutoContinue.js' import { isEnvTruthy } from '../utils/envUtils.js' import { formatTokens, truncateToWidth } from '../utils/format.js' import { consumeEarlyInput } from '../utils/earlyInput.js' import { setMemberActive } from '../utils/swarm/teamHelpers.js' import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox, } from '../utils/swarm/permissionSync.js' import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js' import { getTeamName, getAgentName } from '../utils/teammate.js' import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js' import { injectUserMessageToTeammate, getAllInProcessTeammateTasks, } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState, } from '../tasks/LocalAgentTask/LocalAgentTask.js' import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext, } from '../utils/swarm/leaderPermissionBridge.js' import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js' import { useLogMessages } from '../hooks/useLogMessages.js' import { useFeishuBridge } from '../hooks/useFeishuBridge.js' import { useReplBridge } from '../hooks/useReplBridge.js' import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled, } from '../commands.js' import type { PromptInputMode, QueuedCommand, VimMode, } from '../types/textInputTypes.js' import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic, } from '../components/MessageSelector.js' import { useIdeLogging } from '../hooks/useIdeLogging.js' import { PermissionRequest, type ToolUseConfirm, } from '../components/permissions/PermissionRequest.js' import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js' import { PromptDialog } from '../components/hooks/PromptDialog.js' import type { PromptRequest, PromptResponse } from '../types/hooks.js' import PromptInput from '../components/PromptInput/PromptInput.js' import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js' import { useRemoteSession } from '../hooks/useRemoteSession.js' import { useDirectConnect } from '../hooks/useDirectConnect.js' import type { DirectConnectConfig } from '../server/directConnectManager.js' import { useSSHSession } from '../hooks/useSSHSession.js' import { useAssistantHistory } from '../hooks/useAssistantHistory.js' import type { SSHSession } from '../ssh/createSSHSession.js' import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js' import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js' import { useMoreRight } from '../moreright/useMoreRight.js' import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode, } from '../components/Spinner.js' import { getSystemPrompt } from '../constants/prompts.js' import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js' import { getSystemContext, getUserContext } from '../context.js' import { getMemoryFiles } from '../utils/claudemd.js' import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js' import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts, } from '../cost-tracker.js' import { useCostSummary } from '../costHook.js' import { useFpsMetrics } from '../context/fpsMetrics.js' import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js' import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js' import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences, } from '../history.js' import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js' import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js' import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js' import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js' import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' import { CancelRequestHandler } from '../hooks/useCancelRequest.js' import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js' import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js' import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js' import { errorMessage } from '../utils/errors.js' import { isHumanTurn } from '../utils/messagePredicates.js' import { logError } from '../utils/log.js' // Dead code elimination: conditional imports /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ stripTrailing: () => 0, handleKeyEvent: () => {}, resetAnchor: () => {}, }) const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null // Frustration detection is ant-only (dogfooding). Conditional require so external // builds eliminate the module entirely (including its two O(n) useMemos that run // on every messages change, plus the GrowthBook fetch). const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js') .useFrustrationDetection : () => ({ state: 'closed', handleTranscriptSelect: () => {} }) // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js') .useAntOrgWarningNotification : () => {} // Dead code elimination: conditional import for coordinator mode const getCoordinatorUserContext: ( mcpClients: ReadonlyArray<{ name: string }>, scratchpadDir?: string, ) => { [k: string]: string } = feature('COORDINATOR_MODE') ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext : () => ({}) /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import useCanUseTool from '../hooks/useCanUseTool.js' import type { ToolPermissionContext, Tool } from '../Tool.js' import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdate, } from '../utils/permissions/PermissionUpdate.js' import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js' import { getScratchpadDir, isScratchpadEnabled, } from '../utils/permissions/filesystem.js' import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js' import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js' import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js' import type { AutoUpdaterResult } from '../utils/autoUpdater.js' import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount, } from '../utils/config.js' import { hasConsoleBillingAccess } from '../utils/billing.js' import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } from 'src/services/analytics/index.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createApiMetricsMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags, } from '../utils/messages.js' import { generateSessionTitle } from '../utils/sessionTitle.js' import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG, } from '../constants/xml.js' import { escapeXml } from '../utils/xml.js' import type { ThinkingConfig } from '../utils/thinking.js' import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' import { handlePromptSubmit, type PromptInputHelpers, } from '../utils/handlePromptSubmit.js' import { useQueueProcessor } from '../hooks/useQueueProcessor.js' import { useMailboxBridge } from '../hooks/useMailboxBridge.js' import { queryCheckpoint, logQueryProfileReport, } from '../utils/queryProfiler.js' import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection, } from '../types/message.js' import { query } from '../query.js' import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js' import { getQuerySourceForREPL } from '../utils/promptCategory.js' import { useMergedTools } from '../hooks/useMergedTools.js' import { mergeAndFilterTools } from '../utils/toolPool.js' import { useMergedCommands } from '../hooks/useMergedCommands.js' import { useSkillsChange } from '../hooks/useSkillsChange.js' import { useManagePlugins } from '../hooks/useManagePlugins.js' import { Messages } from '../components/Messages.js' import { TaskListV2 } from '../components/TaskListV2.js' import { TeammateViewHeader } from '../components/TeammateViewHeader.js' import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js' import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' import type { MCPServerConnection } from '../services/mcp/types.js' import type { ScopedMcpServerConfig } from '../services/mcp/types.js' import { randomUUID, type UUID } from 'crypto' import { processSessionStartHooks } from '../utils/sessionStart.js' import { executeSessionEndHooks, getSessionEndHookTimeoutMs, } from '../utils/hooks.js' import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js' import { getTools, assembleToolPool } from '../tools.js' import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js' import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js' import { useMainLoopModel } from '../hooks/useMainLoopModel.js' import { useAppState, useSetAppState, useAppStateStore, } from '../state/AppState.js' import type { ContentBlockParam, ImageBlockParam, } from '@anthropic-ai/sdk/resources/messages.mjs' import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js' import type { PastedContent } from '../utils/config.js' import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug, } from '../utils/plans.js' import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript, } from '../utils/sessionStorage.js' import { deserializeMessages } from '../utils/conversationRecovery.js' import { extractReadFilesFromMessages, extractBashToolsFromMessages, } from '../utils/queryHelpers.js' import { resetMicrocompactState } from '../services/compact/microCompact.js' import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js' import { provisionContentReplacementState, reconstructContentReplacementState, type ContentReplacementRecord, } from '../utils/toolResultStorage.js' import { partialCompactConversation } from '../services/compact/compact.js' import type { LogOption } from '../types/logs.js' import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' import { fileHistoryMakeSnapshot, type FileHistoryState, fileHistoryRewind, type FileHistorySnapshot, copyFileHistoryForResume, fileHistoryEnabled, fileHistoryHasAnyChanges, } from '../utils/fileHistory.js' import { type AttributionState, incrementPromptCount, } from '../utils/commitAttribution.js' import { recordAttributionSnapshot } from '../utils/sessionStorage.js' import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree, } from '../utils/sessionRestore.js' import { isBgSession, updateSessionName, updateSessionActivity, } from '../utils/concurrentSessions.js' import { isInProcessTeammateTask, type InProcessTeammateTaskState, } from '../tasks/InProcessTeammateTask/types.js' import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js' import { useInboxPoller } from '../hooks/useInboxPoller.js' // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} const PROACTIVE_FALSE = () => false const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null /* eslint-enable @typescript-eslint/no-require-imports */ import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js' import type { SandboxAskCallback, NetworkHostPattern, } from '../utils/sandbox/sandbox-adapter.js' import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType, } from '../utils/ide.js' import { useIDEIntegration } from '../hooks/useIDEIntegration.js' import exit from '../commands/exit/index.js' import { ExitFlow } from '../components/ExitFlow.js' import { getCurrentWorktreeSession } from '../utils/worktree.js' import { popAllEditable, enqueue, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter, } from '../utils/messageQueueManager.js' import { useCommandQueue } from '../hooks/useCommandQueue.js' import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js' import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js' import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js' import { diagnosticTracker } from '../services/diagnosticTracking.js' import { handleSpeculationAccept, type ActiveSpeculationState, } from '../services/PromptSuggestion/speculation.js' import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js' import { EffortCallout, shouldShowEffortCallout, } from '../components/EffortCallout.js' import type { EffortValue } from '../utils/effort.js' import { RemoteCallout } from '../components/RemoteCallout.js' /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const AntModelSwitchCallout = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null const shouldShowAntModelSwitch = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js') .shouldShowModelSwitchCallout : (): boolean => false const UndercoverAutoCallout = "external" === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import { activityManager } from '../utils/activityManager.js' import { createAbortController } from '../utils/abortController.js' import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js' import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js' import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js' import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js' import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js' import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js' import { useAwaySummary } from 'src/hooks/useAwaySummary.js' import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js' import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js' import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js' import { getTipToShowOnSpinner, recordShownTip, } from 'src/services/tips/tipScheduler.js' import type { Theme } from 'src/utils/theme.js' import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded, } from 'src/utils/permissions/bypassPermissionsKillswitch.js' import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js' import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js' import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js' import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js' import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js' import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js' import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js' import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js' import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js' import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js' import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js' import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js' import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js' import { DesktopUpsellStartup, shouldShowDesktopUpsellStartup, } from 'src/components/DesktopUpsell/DesktopUpsellStartup.js' import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js' import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js' import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js' import { UserTextMessage } from 'src/components/messages/UserTextMessage.js' import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js' import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js' import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js' import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js' import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js' import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js' import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js' import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js' import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js' import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason, } from '../utils/autoRunIssue.js' import type { HookProgress } from '../types/hooks.js' import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js' /* eslint-disable @typescript-eslint/no-require-imports */ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js')) : null /* eslint-enable @typescript-eslint/no-require-imports */ import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js' import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js' import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE, } from '../buddy/CompanionSprite.js' import { DevBar } from '../components/DevBar.js' // Session manager removed - using AppState now import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' import { REMOTE_SAFE_COMMANDS } from '../commands.js' import type { RemoteMessageContent } from '../utils/teleport/api.js' import { FullscreenLayout, useUnseenDivider, computeUnseenDivider, } from '../components/FullscreenLayout.js' import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled, } from '../utils/fullscreen.js' import { AlternateScreen } from '../ink/components/AlternateScreen.js' import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js' import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type MessageActionsState, type MessageActionsNav, type MessageActionCaps, } from '../components/messageActions.js' import { setClipboard } from '../ink/termio/osc.js' import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' import { createAttachmentMessage, getQueuedCommandAttachments, } from '../utils/attachments.js' // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would // cause useEffect dependency changes and infinite re-render loops. const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [] // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} } // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll // up to read the start → start typing → before this fix, snapped to bottom. // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739 const RECENT_SCROLL_REPIN_WINDOW_MS = 3000 // Use LRU cache to prevent unbounded memory growth // 100 files should be sufficient for most coding sessions while preventing // memory issues when working across many files in large projects function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) const mid = Math.floor(sorted.length / 2) return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]! } /** * Small component to display transcript mode footer with dynamic keybinding. * Must be rendered inside KeybindingSetup to access keybinding context. */ function TranscriptModeFooter({ showAllInTranscript, virtualScroll, searchBadge, suppressShowAll = false, status, }: { showAllInTranscript: boolean virtualScroll: boolean /** Minimap while navigating a closed-bar search. Shows n/N hints + * right-aligned count instead of scroll hints. */ searchBadge?: { current: number; count: number } /** Hide the ctrl+e hint. The [ dump path shares this footer with * env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1), * but ctrl+e only works in the env case — useGlobalKeybindings.tsx * gates on !virtualScrollActive which is env-derived, doesn't know * [ happened. */ suppressShowAll?: boolean /** Transient status (v-for-editor progress). Notifications render inside * PromptInput which isn't mounted in transcript — addNotification queues * but nothing draws it. */ status?: string }): React.ReactNode { const toggleShortcut = useShortcutDisplay( 'app:toggleTranscript', 'Global', 'ctrl+o', ) const showAllShortcut = useShortcutDisplay( 'transcript:toggleShowAll', 'Transcript', 'ctrl+e', ) return ( Showing detailed transcript · {toggleShortcut} to toggle {searchBadge ? ' · n/N to navigate' : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? '' : ` · ${showAllShortcut} to ${showAllInTranscript ? 'collapse' : 'show all'}`} {status ? ( // v-for-editor render progress — transient, preempts the search // badge since the user just pressed v and wants to see what's // happening. Clears after 4s. <> {status} ) : searchBadge ? ( // Engine-counted — close enough for a rough location hint. May // drift from render-count for ghost/phantom messages. <> {searchBadge.current}/{searchBadge.count} {' '} ) : null} ) } /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter * so swapping them in the bottom slot doesn't shift ScrollBox height. * useSearchInput handles readline editing; we report query changes and * render the counter. Incremental — re-search + highlight per keystroke. */ function TranscriptSearchBar({ jumpRef, count, current, onClose, onCancel, setHighlight, initialQuery, }: { jumpRef: RefObject count: number current: number /** Enter — commit. Query persists for n/N. */ onClose: (lastQuery: string) => void /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ onCancel: () => void setHighlight: (query: string) => void // Seed with the previous query (less: / shows last pattern). Mount-fire // of the effect re-scans with the same query — idempotent (same matches, // nearest-ptr, same highlights). User can edit or clear. initialQuery: string }): React.ReactNode { const { query, cursorOffset } = useSearchInput({ isActive: true, initialQuery, onExit: () => onClose(query), onCancel, }) // Index warm-up runs before the query effect so it measures the real // cost — otherwise setSearchQuery fills the cache first and warm // reports ~0ms while the user felt the actual lag. // First / in a transcript session pays the extractSearchText cost. // Subsequent / return 0 immediately (indexWarmed ref in VML). // Transcript is frozen at ctrl+o so the cache stays valid. // Initial 'building' so warmDone is false on mount — the [query] effect // waits for the warm effect's first resolve instead of racing it. With // null initial, warmDone would be true on mount → [query] fires → // setSearchQuery fills cache → warm reports ~0ms while the user felt // the real lag. const [indexStatus, setIndexStatus] = React.useState< 'building' | { ms: number } | null >('building') React.useEffect(() => { let alive = true const warm = jumpRef.current?.warmSearchIndex if (!warm) { setIndexStatus(null) // VML not mounted yet — rare, skip indicator return } setIndexStatus('building') warm().then(ms => { if (!alive) return // <20ms = imperceptible. No point showing "indexed in 3ms". if (ms < 20) { setIndexStatus(null) } else { setIndexStatus({ ms }) setTimeout(() => alive && setIndexStatus(null), 2000) } }) return () => { alive = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // mount-only: bar opens once per / // Gate the query effect on warm completion. setHighlight stays instant // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. const warmDone = indexStatus !== 'building' useEffect(() => { if (!warmDone) return jumpRef.current?.setSearchQuery(query) setHighlight(query) // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, warmDone]) const off = cursorOffset const cursorChar = off < query.length ? query[off] : ' ' return ( / {query.slice(0, off)} {cursorChar} {off < query.length && {query.slice(off + 1)}} {indexStatus === 'building' ? ( indexing… ) : indexStatus ? ( indexed in {indexStatus.ms}ms ) : count === 0 && query ? ( no matches ) : count > 0 ? ( // Engine-counted (indexOf on extractSearchText). May drift from // render-count for ghost/phantom messages — badge is a rough // location hint. scanElement gives exact per-message positions // but counting ALL would cost ~1-3ms × matched-messages. {current}/{count} {' '} ) : null} ) } const TITLE_ANIMATION_FRAMES = ['⠂', '⠐'] const TITLE_STATIC_PREFIX = '✳' const TITLE_ANIMATION_INTERVAL_MS = 960 /** * Sets the terminal tab title, with an animated prefix glyph while a query * is running. Isolated from REPL so the 960ms animation tick re-renders only * this leaf component (which returns null — pure side-effect) instead of the * entire REPL tree. Before extraction, the tick was ~1 REPL render/sec for * the duration of every turn, dragging PromptInput and friends along. */ function AnimatedTerminalTitle({ isAnimating, title, disabled, noPrefix, }: { isAnimating: boolean title: string disabled: boolean noPrefix: boolean }): null { const terminalFocused = useTerminalFocus() const [frame, setFrame] = useState(0) useEffect(() => { if (disabled || noPrefix || !isAnimating || !terminalFocused) return const interval = setInterval( setFrame => setFrame(f => (f + 1) % TITLE_ANIMATION_FRAMES.length), TITLE_ANIMATION_INTERVAL_MS, setFrame, ) return () => clearInterval(interval) }, [disabled, noPrefix, isAnimating, terminalFocused]) const prefix = isAnimating ? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX) : TITLE_STATIC_PREFIX useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`) return null } export type Props = { commands: Command[] debug: boolean initialTools: Tool[] // Initial messages to populate the REPL with initialMessages?: MessageType[] // Deferred hook messages promise — REPL renders immediately and injects // hook messages when they resolve. Awaited before the first API call. pendingHookMessages?: Promise initialFileHistorySnapshots?: FileHistorySnapshot[] // Content-replacement records from a resumed session's transcript — used to // reconstruct contentReplacementState so the same results are re-replaced initialContentReplacements?: ContentReplacementRecord[] // Initial agent context for session resume (name/color set via /rename or /color) initialAgentName?: string initialAgentColor?: AgentColorName mcpClients?: MCPServerConnection[] dynamicMcpConfig?: Record autoConnectIdeFlag?: boolean strictMcpConfig?: boolean systemPrompt?: string appendSystemPrompt?: string // Optional callback invoked before query execution // Called after user message is added to conversation but before API call // Return false to prevent query execution onBeforeQuery?: ( input: string, newMessages: MessageType[], ) => Promise // Optional callback when a turn completes (model finishes responding) onTurnComplete?: (messages: MessageType[]) => void | Promise // When true, disables REPL input (hides prompt and prevents message selector) disabled?: boolean // Optional agent definition to use for the main thread mainThreadAgentDefinition?: AgentDefinition // When true, disables all slash commands disableSlashCommands?: boolean // Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks. taskListId?: string // Remote session config for --remote mode (uses CCR as execution engine) remoteSessionConfig?: RemoteSessionConfig // Direct connect config for `claude connect` mode (connects to a claude server) directConnectConfig?: DirectConnectConfig // SSH session for `claude ssh` mode (local REPL, remote tools over ssh) sshSession?: SSHSession // Thinking configuration to use when thinking is enabled thinkingConfig: ThinkingConfig } export type Screen = 'prompt' | 'transcript' export function REPL({ commands: initialCommands, debug, initialTools, initialMessages, pendingHookMessages, initialFileHistorySnapshots, initialContentReplacements, initialAgentName, initialAgentColor, mcpClients: initialMcpClients, dynamicMcpConfig: initialDynamicMcpConfig, autoConnectIdeFlag, strictMcpConfig = false, systemPrompt: customSystemPrompt, appendSystemPrompt, onBeforeQuery, onTurnComplete, disabled = false, mainThreadAgentDefinition: initialMainThreadAgentDefinition, disableSlashCommands = false, taskListId, remoteSessionConfig, directConnectConfig, sshSession, thinkingConfig, }: Props): React.ReactNode { const isRemoteSession = !!remoteSessionConfig // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ // includes, and these were on the render path (hot during PageUp spam). const titleDisabled = useMemo( () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), [], ) const moreRightEnabled = useMemo( () => "external" === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), [], ) const disableVirtualScroll = useMemo( () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), [], ) const disableMessageActions = feature('MESSAGE_ACTIONS') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useMemo( () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), [], ) : false // Log REPL mount/unmount lifecycle useEffect(() => { logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`) return () => logForDebugging(`[REPL:unmount] REPL unmounting`) }, [disabled]) // Agent definition is state so /resume can update it mid-session const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState( initialMainThreadAgentDefinition, ) const toolPermissionContext = useAppState(s => s.toolPermissionContext) const verbose = useAppState(s => s.verbose) const mcp = useAppState(s => s.mcp) const plugins = useAppState(s => s.plugins) const agentDefinitions = useAppState(s => s.agentDefinitions) const fileHistory = useAppState(s => s.fileHistory) const initialMessage = useAppState(s => s.initialMessage) const queuedCommands = useCommandQueue() // feature() is a build-time constant — dead code elimination removes the hook // call entirely in external builds, so this is safe despite looking conditional. // These fields contain excluded strings that must not appear in external builds. const spinnerTip = useAppState(s => s.spinnerTip) const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks' const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest) const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest) const teamContext = useAppState(s => s.teamContext) const tasks = useAppState(s => s.tasks) const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions) const elicitation = useAppState(s => s.elicitation) const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice) const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending) const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) const setAppState = useSetAppState() // Bootstrap: retained local_agent that hasn't loaded disk yet → read // sidechain JSONL and UUID-merge with whatever stream has appended so far. // Stream appends immediately on retain (no defer); bootstrap fills the // prefix. Disk-write-before-yield means live is always a suffix of disk. const viewedLocalAgent = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined const needsBootstrap = isLocalAgentTask(viewedLocalAgent) && viewedLocalAgent.retain && !viewedLocalAgent.diskLoaded useEffect(() => { if (!viewingAgentTaskId || !needsBootstrap) return const taskId = viewingAgentTaskId void getAgentTranscript(asAgentId(taskId)).then(result => { setAppState(prev => { const t = prev.tasks[taskId] if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev const live = t.messages ?? [] const liveUuids = new Set(live.map(m => m.uuid)) const diskOnly = result ? result.messages.filter(m => !liveUuids.has(m.uuid)) : [] return { ...prev, tasks: { ...prev.tasks, [taskId]: { ...t, messages: [...diskOnly, ...live], diskLoaded: true, }, }, } }) }) }, [viewingAgentTaskId, needsBootstrap, setAppState]) const store = useAppStateStore() const terminal = useTerminalNotification() const mainLoopModel = useMainLoopModel() // Note: standaloneAgentContext is initialized in main.tsx (via initialState) or // ResumeConversation.tsx (via setAppState before rendering REPL) to avoid // useEffect-based state initialization on mount (per CLAUDE.md guidelines) // Local state for commands (hot-reloadable when skill files change) const [localCommands, setLocalCommands] = useState(initialCommands) // Watch for skill file changes and reload all commands useSkillsChange( isRemoteSession ? undefined : getProjectRoot(), setLocalCommands, ) // Track proactive mode for tools dependency - SleepTool filters by proactive state const proactiveActive = React.useSyncExternalStore( proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE, ) // BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which // /brief flips mid-session alongside isBriefOnly. The memo below needs a // React-visible dep to re-run getTools() when that happens; isBriefOnly is // the AppState mirror that triggers the re-render. Without this, toggling // /brief mid-session leaves the stale tool list (no SendUserMessage) and // the model emits plain text the brief filter hides. const isBriefOnly = useAppState(s => s.isBriefOnly) const localTools = useMemo( () => getTools(toolPermissionContext), [toolPermissionContext, proactiveActive, isBriefOnly], ) useKickOffCheckAndDisableBypassPermissionsIfNeeded() useKickOffCheckAndDisableAutoModeIfNeeded() const [dynamicMcpConfig, setDynamicMcpConfig] = useState< Record | undefined >(initialDynamicMcpConfig) const onChangeDynamicMcpConfig = useCallback( (config: Record) => { setDynamicMcpConfig(config) }, [setDynamicMcpConfig], ) const [screen, setScreen] = useState('prompt') const [showAllInTranscript, setShowAllInTranscript] = useState(false) // [ forces the dump-to-scrollback path inside transcript mode. Separate // from CLAUDE_CODE_NO_FLICKER=0 (which is process-lifetime) — this is // ephemeral, reset on transcript exit. Diagnostic escape hatch so // terminal/tmux native cmd-F can search the full flat render. const [dumpMode, setDumpMode] = useState(false) // v-for-editor render progress. Inline in the footer — notifications // render inside PromptInput which isn't mounted in transcript. const [editorStatus, setEditorStatus] = useState('') // Incremented on transcript exit. Async v-render captures this at start; // each status write no-ops if stale (user left transcript mid-render — // the stable setState would otherwise stamp a ghost toast into the next // session). Also clears any pending 4s auto-clear. const editorGenRef = useRef(0) const editorTimerRef = useRef | undefined>( undefined, ) const editorRenderingRef = useRef(false) const { addNotification, removeNotification } = useNotifications() // eslint-disable-next-line prefer-const let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP const mcpClients = useMergedClients(initialMcpClients, mcp.clients) // IDE integration const [ideSelection, setIDESelection] = useState( undefined, ) const [ideToInstallExtension, setIDEToInstallExtension] = useState(null) const [ideInstallationStatus, setIDEInstallationStatus] = useState(null) const [showIdeOnboarding, setShowIdeOnboarding] = useState(false) // Dead code elimination: model switch callout state (ant-only) const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => { if ("external" === 'ant') { return shouldShowAntModelSwitch() } return false }) const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel), ) const showRemoteCallout = useAppState(s => s.showRemoteCallout) const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup(), ) // notifications useModelMigrationNotifications() useCanSwitchToExistingSubscription() useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }) useMcpConnectivityStatus({ mcpClients }) useAutoModeUnavailableNotification() usePluginInstallationStatus() usePluginAutoupdateNotification() useSettingsErrors() useRateLimitWarningNotification(mainLoopModel) useFastModeNotification() useDeprecationWarningNotification(mainLoopModel) useNpmDeprecationNotification() useAntOrgWarningNotification() useInstallMessages() useChromeExtensionNotification() useOfficialMarketplaceNotification() useLspInitializationNotification() useTeammateLifecycleNotification() const { recommendation: lspRecommendation, handleResponse: handleLspResponse, } = useLspPluginRecommendation() const { recommendation: hintRecommendation, handleResponse: handleHintResponse, } = useClaudeCodeHintRecommendation() // Memoize the combined initial tools array to prevent reference changes const combinedInitialTools = useMemo(() => { return [...localTools, ...initialTools] }, [localTools, initialTools]) // Initialize plugin management useManagePlugins({ enabled: !isRemoteSession }) const tasksV2 = useTasksV2WithCollapseEffect() // Start background plugin installations // SECURITY: This code is guaranteed to run ONLY after the "trust this folder" dialog // has been confirmed by the user. The trust dialog is shown in cli.tsx (line ~387) // before the REPL component is rendered. The dialog blocks execution until the user // accepts, and only then is the REPL component mounted and this effect runs. // This ensures that plugin installations from repository and user settings only // happen after explicit user consent to trust the current working directory. useEffect(() => { if (isRemoteSession) return void performStartupChecks(setAppState) }, [setAppState, isRemoteSession]) // Allow Claude in Chrome MCP to send prompts through MCP notifications // and sync permission mode changes to the Chrome extension usePromptsFromClaudeInChrome( isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode, ) // Initialize swarm features: teammate hooks and context // Handles both fresh spawns and resumed teammate sessions useSwarmInitialization(setAppState, initialMessages, { enabled: !isRemoteSession, }) const mergedTools = useMergedTools( combinedInitialTools, mcp.tools, toolPermissionContext, ) // Apply agent tool restrictions if mainThreadAgentDefinition is set const { tools, allowedAgentTypes } = useMemo(() => { if (!mainThreadAgentDefinition) { return { tools: mergedTools, allowedAgentTypes: undefined as string[] | undefined, } } const resolved = resolveAgentTools( mainThreadAgentDefinition, mergedTools, false, true, ) return { tools: resolved.resolvedTools, allowedAgentTypes: resolved.allowedAgentTypes, } }, [mainThreadAgentDefinition, mergedTools]) // Merge commands from local state, plugins, and MCP const commandsWithPlugins = useMergedCommands( localCommands, plugins.commands as Command[], ) const mergedCommands = useMergedCommands( commandsWithPlugins, mcp.commands as Command[], ) // Filter out all commands if disableSlashCommands is true const commands = useMemo( () => (disableSlashCommands ? [] : mergedCommands), [disableSlashCommands, mergedCommands], ) useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients) useIdeSelection( isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection, ) const [streamMode, setStreamMode] = useState('responding') // Ref mirror so onSubmit can read the latest value without adding // streamMode to its deps. streamMode flips between // requesting/responding/tool-use ~10x per turn during streaming; having it // in onSubmit's deps was recreating onSubmit on every flip, which // cascaded into PromptInput prop churn and downstream useCallback/useMemo // invalidation. The only consumers inside callbacks are debug logging and // telemetry (handlePromptSubmit.ts), so a stale-by-one-render value is // harmless — but ref mirrors sync on every render anyway so it's fresh. const streamModeRef = useRef(streamMode) streamModeRef.current = streamMode const [streamingToolUses, setStreamingToolUses] = useState< StreamingToolUse[] >([]) const [streamingThinking, setStreamingThinking] = useState(null) // Auto-hide streaming thinking after 30 seconds of being completed useEffect(() => { if ( streamingThinking && !streamingThinking.isStreaming && streamingThinking.streamingEndedAt ) { const elapsed = Date.now() - streamingThinking.streamingEndedAt const remaining = 30000 - elapsed if (remaining > 0) { const timer = setTimeout(setStreamingThinking, remaining, null) return () => clearTimeout(timer) } else { setStreamingThinking(null) } } }, [streamingThinking]) const [abortController, setAbortController] = useState(null) // Ref that always points to the current abort controller, used by the // REPL bridge to abort the active query when a remote interrupt arrives. const abortControllerRef = useRef(null) abortControllerRef.current = abortController // Ref for the bridge result callback — set after useReplBridge initializes, // read in the onQuery finally block to notify mobile clients that a turn ended. const sendBridgeResultRef = useRef<() => void>(() => {}) // Ref for the synchronous restore callback — set after restoreMessageSync is // defined, read in the onQuery finally block for auto-restore on interrupt. const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}) // Ref to the fullscreen layout's scroll box for keyboard scrolling. // Null when fullscreen mode is disabled (ref never attached). const scrollRef = useRef(null) // Separate ref for the modal slot's inner ScrollBox — passed through // FullscreenLayout → ModalContext so Tabs can attach it to its own // ScrollBox for tall content (e.g. /status's MCP-server list). NOT // keyboard-driven — ScrollKeybindingHandler stays on the outer ref so // PgUp/PgDn/wheel always scroll the transcript behind the modal. // Plumbing kept for future modal-scroll wiring. const modalScrollRef = useRef(null) // Timestamp of the last user-initiated scroll (wheel, PgUp/PgDn, ctrl+u, // End/Home, G, drag-to-scroll). Stamped in composedOnScroll — the single // chokepoint ScrollKeybindingHandler calls for every user scroll action. // Programmatic scrolls (repinScroll's scrollToBottom, sticky auto-follow) // do NOT go through composedOnScroll, so they don't stamp this. Ref not // state: no re-render on every wheel tick. const lastUserScrollTsRef = useRef(0) // Synchronous state machine for the query lifecycle. Replaces the // error-prone dual-state pattern where isLoading (React state, async // batched) and isQueryRunning (ref, sync) could desync. See QueryGuard.ts. const queryGuard = React.useRef(new QueryGuard()).current // Subscribe to the guard — true during dispatching or running. // This is the single source of truth for "is a local query in flight". const isQueryActive = React.useSyncExternalStore( queryGuard.subscribe, queryGuard.getSnapshot, ) // /goal auto-continuation: enqueues meta prompts when a goal is active and // the query guard transitions from active -> idle. useGoalAutoContinue(queryGuard) // Separate loading flag for operations outside the local query guard: // remote sessions (useRemoteSession / useDirectConnect) and foregrounded // background tasks (useSessionBackgrounding). These don't route through // onQuery / queryGuard, so they need their own spinner-visibility state. // Initialize true if remote mode with initial prompt (CCR processing it). const [isExternalLoading, setIsExternalLoadingRaw] = React.useState( remoteSessionConfig?.hasInitialPrompt ?? false, ) // Derived: any loading source active. Read-only — no setter. Local query // loading is driven by queryGuard (reserve/tryStart/end/cancelReservation), // external loading by setIsExternalLoading. const isLoading = isQueryActive || isExternalLoading // Elapsed time is computed by SpinnerWithVerb from these refs on each // animation frame, avoiding a useInterval that re-renders the entire REPL. const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState< string | undefined >(undefined) // messagesRef.current.length at the moment userInputOnProcessing was set. // The placeholder hides once displayedMessages grows past this — i.e. the // real user message has landed in the visible transcript. const userInputBaselineRef = React.useRef(0) // True while the submitted prompt is being processed but its user message // hasn't reached setMessages yet. setMessages uses this to keep the // baseline in sync when unrelated async messages (bridge status, hook // results, scheduled tasks) land during that window. const userMessagePendingRef = React.useRef(false) // Wall-clock time tracking refs for accurate elapsed time calculation const loadingStartTimeRef = React.useRef(0) const totalPausedMsRef = React.useRef(0) const pauseStartTimeRef = React.useRef(null) const resetTimingRefs = React.useCallback(() => { loadingStartTimeRef.current = Date.now() totalPausedMsRef.current = 0 pauseStartTimeRef.current = null }, []) // Reset timing refs inline when isQueryActive transitions false→true. // queryGuard.reserve() (in executeUserInput) fires BEFORE processUserInput's // first await, but the ref reset in onQuery's try block runs AFTER. During // that gap, React renders the spinner with loadingStartTimeRef=0, computing // elapsedTimeMs = Date.now() - 0 ≈ 56 years. This inline reset runs on the // first render where isQueryActive is observed true — the same render that // first shows the spinner — so the ref is correct by the time the spinner // reads it. See INC-4549. const wasQueryActiveRef = React.useRef(false) if (isQueryActive && !wasQueryActiveRef.current) { resetTimingRefs() } wasQueryActiveRef.current = isQueryActive // Wrapper for setIsExternalLoading that resets timing refs on transition // to true — SpinnerWithVerb reads these for elapsed time, so they must be // reset for remote sessions / foregrounded tasks too (not just local // queries, which reset them in onQuery). Without this, a remote-only // session would show ~56 years elapsed (Date.now() - 0). const setIsExternalLoading = React.useCallback( (value: boolean) => { setIsExternalLoadingRaw(value) if (value) resetTimingRefs() }, [resetTimingRefs], ) // Start time of the first turn that had swarm teammates running // Used to compute total elapsed time (including teammate execution) for the deferred message const swarmStartTimeRef = React.useRef(null) const swarmBudgetInfoRef = React.useRef< { tokens: number; limit: number; nudges: number } | undefined >(undefined) // Ref to track current focusedInputDialog for use in callbacks // This avoids stale closures when checking dialog state in timer callbacks const focusedInputDialogRef = React.useRef>(undefined) // How long after the last keystroke before deferred dialogs are shown const PROMPT_SUPPRESSION_MS = 1500 // True when user is actively typing — defers interrupt dialogs so keystrokes // don't accidentally dismiss or answer a permission prompt the user hasn't read yet. const [isPromptInputActive, setIsPromptInputActive] = React.useState(false) const [autoUpdaterResult, setAutoUpdaterResult] = useState(null) useEffect(() => { if (autoUpdaterResult?.notifications) { autoUpdaterResult.notifications.forEach(notification => { addNotification({ key: 'auto-updater-notification', text: notification, priority: 'low', }) }) } }, [autoUpdaterResult, addNotification]) // tmux + fullscreen + `mouse off`: one-time hint that wheel won't scroll. // We no longer mutate tmux's session-scoped mouse option (it poisoned // sibling panes); tmux users already know this tradeoff from vim/less. useEffect(() => { if (isFullscreenEnvEnabled()) { void maybeGetTmuxMouseHint().then(hint => { if (hint) { addNotification({ key: 'tmux-mouse-hint', text: hint, priority: 'low', }) } }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const [showUndercoverCallout, setShowUndercoverCallout] = useState(false) useEffect(() => { if ("external" === 'ant') { void (async () => { // Wait for repo classification to settle (memoized, no-op if primed). const { isInternalModelRepo } = await import( '../utils/commitAttribution.js' ) await isInternalModelRepo() const { shouldShowUndercoverAutoNotice } = await import( '../utils/undercover.js' ) if (shouldShowUndercoverAutoNotice()) { setShowUndercoverCallout(true) } })() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const [toolJSX, setToolJSXInternal] = useState<{ jsx: React.ReactNode | null shouldHidePromptInput: boolean shouldContinueAnimation?: true showSpinner?: boolean isLocalJSXCommand?: boolean isImmediate?: boolean } | null>(null) // Track local JSX commands separately so tools can't overwrite them. // This enables "immediate" commands (like /btw) to persist while Claude is processing. const localJSXCommandRef = useRef<{ jsx: React.ReactNode | null shouldHidePromptInput: boolean shouldContinueAnimation?: true showSpinner?: boolean isLocalJSXCommand: true } | null>(null) // Wrapper for setToolJSX that preserves local JSX commands (like /btw). // When a local JSX command is active, we ignore updates from tools // unless they explicitly set clearLocalJSX: true (from onDone callbacks). // // TO ADD A NEW IMMEDIATE COMMAND: // 1. Set `immediate: true` in the command definition // 2. Set `isLocalJSXCommand: true` when calling setToolJSX in the command's JSX // 3. In the onDone callback, use `setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true })` // to explicitly clear the overlay when the user dismisses it const setToolJSX = useCallback( ( args: { jsx: React.ReactNode | null shouldHidePromptInput: boolean shouldContinueAnimation?: true showSpinner?: boolean isLocalJSXCommand?: boolean clearLocalJSX?: boolean } | null, ) => { // If setting a local JSX command, store it in the ref if (args?.isLocalJSXCommand) { const { clearLocalJSX: _, ...rest } = args localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true } setToolJSXInternal(rest) return } // If there's an active local JSX command in the ref if (localJSXCommandRef.current) { // Allow clearing only if explicitly requested (from onDone callbacks) if (args?.clearLocalJSX) { localJSXCommandRef.current = null setToolJSXInternal(null) return } // Otherwise, keep the local JSX command visible - ignore tool updates return } // No active local JSX command, allow any update if (args?.clearLocalJSX) { setToolJSXInternal(null) return } setToolJSXInternal(args) }, [], ) const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState< ToolUseConfirm[] >([]) // Sticky footer JSX registered by permission request components (currently // only ExitPlanModePermissionRequest). Renders in FullscreenLayout's `bottom` // slot so response options stay visible while the user scrolls a long plan. const [permissionStickyFooter, setPermissionStickyFooter] = useState(null) const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = useState< Array<{ hostPattern: NetworkHostPattern resolvePromise: (allowConnection: boolean) => void }> >([]) const [promptQueue, setPromptQueue] = useState< Array<{ request: PromptRequest title: string toolInputSummary?: string | null resolve: (response: PromptResponse) => void reject: (error: Error) => void }> >([]) const [questionRequest, setQuestionRequest] = useState(null) useEffect(() => { const { questionService } = require('../services/question/questionService.js') as typeof import('../services/question/questionService.js') // Bridge cleanup keyed by question request id, so a local reply/reject can // cancel the remote prompt (and vice versa) whichever side answers first. const bridgeCleanups = new Map void>() const onAsked = ( request: import('../services/question/questionService.js').QuestionRequest, ) => { setQuestionRequest(request) // When the REPL bridge is connected, forward the question to the remote // user (e.g. on claude.ai) as a can_use_tool control_request, racing it // against the local overlay. Mirrors the sandbox permission pattern. if (!feature('BRIDGE_MODE')) return const bridgeCallbacks = store.getState().replBridgePermissionCallbacks if (!bridgeCallbacks) return const bridgeRequestId = randomUUID() bridgeCallbacks.sendRequest( bridgeRequestId, 'AskUserQuestion', { questions: request.questions }, request.id, request.questions.map(q => q.question).join(' | '), ) const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => { bridgeCleanups.delete(request.id) unsubscribe() if (response.behavior === 'allow') { // CCR returns per-question answers when it has a dedicated renderer; // fall back to first option so a generic allow degrades gracefully. const remote = response.updatedInput?.answers const answers = request.questions.map((q, i) => { const provided = Array.isArray(remote) ? remote[i] : undefined if (Array.isArray(provided)) return provided.map(String) if (typeof provided === 'string') return [provided] const first = q.options[0]?.label return first ? [first] : [] }) questionService.reply({ requestID: request.id, answers }) } else { questionService.reject(request.id) } }) bridgeCleanups.set(request.id, () => { unsubscribe() bridgeCallbacks.cancelRequest(bridgeRequestId) }) } // Local (or remote) resolution: dismiss the overlay and tear down the // matching remote prompt so it doesn't linger on the other side. const onResolved = (payload: { requestID: string }) => { setQuestionRequest(prev => prev && prev.id === payload.requestID ? null : prev, ) const cleanup = bridgeCleanups.get(payload.requestID) if (cleanup) { bridgeCleanups.delete(payload.requestID) cleanup() } } questionService.events.on('asked', onAsked) questionService.events.on('replied', onResolved) questionService.events.on('rejected', onResolved) return () => { questionService.events.off('asked', onAsked) questionService.events.off('replied', onResolved) questionService.events.off('rejected', onResolved) for (const cleanup of bridgeCleanups.values()) cleanup() bridgeCleanups.clear() } }, [store]) // Track bridge cleanup functions for sandbox permission requests so the // local dialog handler can cancel the remote prompt when the local user // responds first. Keyed by host to support concurrent same-host requests. const sandboxBridgeCleanupRef = useRef 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 [autoGeneratedTitle, setAutoGeneratedTitle] = useState() // Gates the one-shot title generation call. Seeded true on resume // (initialMessages present) so we don't re-title a resumed session // from mid-conversation context. const titleGenerationAttemptedRef = useRef((initialMessages?.length ?? 0) > 0) const agentTitle = mainThreadAgentDefinition?.agentType const terminalTitle = sessionTitle ?? agentTitle ?? autoGeneratedTitle ?? 'Codev' 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 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( 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(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) => { 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(null) const cursorNavRef = useRef(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('prompt') const [stashedPrompt, setStashedPrompt] = useState< | { text: string cursorOffset: number pastedContents: Record } | 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>( 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 >({}) 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(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(null) const [spinnerColor, setSpinnerColor] = useState(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('INSERT') const [showBashesDialog, setShowBashesDialog] = useState( 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 // auto-generated title shouldn't carry over. titleGenerationAttemptedRef.current = true setAutoGeneratedTitle(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()) 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()) // 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()) // 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(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(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' | 'question' | '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' if (allowDialogsWithAnimation && questionRequest) return 'question' // 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' || focusedInputDialog === 'question' 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' || prevDialogRef.current === 'question' const now = focusedInputDialog === 'tool-permission' || focusedInputDialog === 'question' 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: ( <> sandbox disabled · /sandbox ), 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 => new Promise((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(), loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, dynamicSkillDirTriggers: new Set(), 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() 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[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, overrideSystemPrompt?: string, ) => { // 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 && !titleGenerationAttemptedRef.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 → ), local-command headers // (/help → ), and bash-mode (!cmd → ). // 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}>`) ) { titleGenerationAttemptedRef.current = true void generateSessionTitle(text, new AbortController().signal).then( title => { if (title) setAutoGeneratedTitle(title) else titleGenerationAttemptedRef.current = false }, () => { titleGenerationAttemptedRef.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, overrideSystemPrompt, }) 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, input?: string, effort?: EffortValue, overrideSystemPrompt?: string, ): Promise => { // 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, overrideSystemPrompt, ) } 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.end(thisGeneration)) { setLastQueryCompletionTime(Date.now()) skipIdleCheckRef.current = false // Always reset loading state in finally - this ensures cleanup even // if onQueryImpl throws. onTurnComplete is called separately in // onQueryImpl only on successful completion. resetLoadingState() await mrOnTurnComplete( messagesRef.current, abortController.signal.aborted, ) // Notify bridge clients that the turn is complete so mobile apps // can stop the spark animation and show post-turn UI. sendBridgeResultRef.current() // Auto-hide tungsten panel content at turn end (ant-only), but keep // tungstenActiveSession set so the pill stays in the footer and the user // can reopen the panel. Background tmux tasks (e.g. /hunter) run for // minutes — wiping the session made the pill disappear entirely, forcing // the user to re-invoke Tmux just to peek. Skip on abort so the panel // stays open for inspection (matches the turn-duration guard below). if ( "external" === 'ant' && !abortController.signal.aborted ) { setAppState(prev => { if (prev.tungstenActiveSession === undefined) return prev if (prev.tungstenPanelAutoHidden === true) return prev return { ...prev, tungstenPanelAutoHidden: true } }) } // Capture budget info before clearing (ant-only) let budgetInfo: | { tokens: number; limit: number; nudges: number } | undefined if (feature('TOKEN_BUDGET')) { if ( getCurrentTurnTokenBudget() !== null && getCurrentTurnTokenBudget()! > 0 && !abortController.signal.aborted ) { budgetInfo = { tokens: getTurnOutputTokens(), limit: getCurrentTurnTokenBudget()!, nudges: getBudgetContinuationCount(), } } snapshotOutputTokensForTurn(null) } // Add turn duration message for turns longer than 30s or with a budget // Skip if user aborted or if in loop mode (too noisy between ticks) // Defer if swarm teammates are still running (show when they finish) const turnDurationMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current if ( (turnDurationMs > 30000 || budgetInfo !== undefined) && !abortController.signal.aborted && !proactiveActive ) { const hasRunningSwarmAgents = getAllInProcessTeammateTasks( store.getState().tasks, ).some(t => t.status === 'running') if (hasRunningSwarmAgents) { // Only record start time on the first deferred turn if (swarmStartTimeRef.current === null) { swarmStartTimeRef.current = loadingStartTimeRef.current } // Always update budget — later turns may carry the actual budget if (budgetInfo) { swarmBudgetInfoRef.current = budgetInfo } } else { setMessages(prev => [ ...prev, createTurnDurationMessage( turnDurationMs, budgetInfo, count(prev, isLoggableMessage), ), ]) } } // Clear the controller so CancelRequestHandler's canCancelRunningTask // reads false at the idle prompt. Without this, the stale non-aborted // controller makes ctrl+c fire onCancel() (aborting nothing) instead of // propagating to the double-press exit flow. setAbortController(null) } // Auto-restore: if the user interrupted before any meaningful response // arrived, rewind the conversation and restore their prompt — same as // opening the message selector and picking the last message. // This runs OUTSIDE the queryGuard.end() check because onCancel calls // forceEnd(), which bumps the generation so end() returns false above. // Guards: reason === 'user-cancel' (onCancel/Esc; programmatic aborts // use 'background'/'interrupt' and must not rewind — note abort() with // no args sets reason to a DOMException, not undefined), !isActive (no // newer query started — cancel+resubmit race), empty input (don't // clobber text typed during loading), no queued commands (user queued // B while A was loading → they've moved on, don't restore A; also // avoids removeLastFromHistory removing B's entry instead of A's), // not viewing a teammate (messagesRef is the main conversation — the // old Up-arrow quick-restore had this guard, preserve it). if ( abortController.signal.reason === 'user-cancel' && !queryGuard.isActive && inputValueRef.current === '' && getCommandQueueLength() === 0 && !store.getState().viewingAgentTaskId ) { const msgs = messagesRef.current const lastUserMsg = msgs.findLast(selectableUserMessagesFilter) if (lastUserMsg) { const idx = msgs.lastIndexOf(lastUserMsg) if (messagesAfterAreOnlySynthetic(msgs, idx)) { // The submit is being undone — undo its history entry too, // otherwise Up-arrow shows the restored text twice. removeLastFromHistory() restoreMessageSyncRef.current(lastUserMsg) } } } } }, [ onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, ], ) // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message const initialMessageRef = useRef(false) useEffect(() => { const pending = initialMessage if (!pending || isLoading || initialMessageRef.current) return // Mark as processing to prevent re-entry initialMessageRef.current = true async function processInitialMessage( initialMsg: NonNullable, ) { // Clear context if requested (plan mode exit) if (initialMsg.clearContext) { // Preserve the plan slug before clearing context, so the new session // can access the same plan file after regenerateSessionId() const oldPlanSlug = initialMsg.message.planContent ? getPlanSlug() : undefined const { clearConversation } = await import( '../commands/clear/conversation.js' ) await clearConversation({ setMessages, readFileState: readFileState.current, discoveredSkillNames: discoveredSkillNamesRef.current, loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, getAppState: () => store.getState(), setAppState, setConversationId, }) titleGenerationAttemptedRef.current = false setAutoGeneratedTitle(undefined) bashTools.current.clear() bashToolsProcessedIdx.current = 0 // Restore the plan slug for the new session so getPlan() finds the file if (oldPlanSlug) { setPlanSlug(getSessionId(), oldPlanSlug) } } // Atomically: clear initial message, set permission mode and rules, and store plan for verification const shouldStorePlanForVerification = initialMsg.message.planContent && "external" === 'ant' && isEnvTruthy(undefined) setAppState(prev => { // Build and apply permission updates (mode + allowedPrompts rules) let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates( prev.toolPermissionContext, buildPermissionUpdates( initialMsg.mode, initialMsg.allowedPrompts, ), ) : prev.toolPermissionContext // For auto, override the mode (buildPermissionUpdates maps // it to 'default' via toExternalPermissionMode) and strip dangerous rules if (feature('TRANSCRIPT_CLASSIFIER') && initialMsg.mode === 'auto') { updatedToolPermissionContext = stripDangerousPermissionsForAutoMode({ ...updatedToolPermissionContext, mode: 'auto', prePlanMode: undefined, }) } return { ...prev, initialMessage: null, toolPermissionContext: updatedToolPermissionContext, ...(shouldStorePlanForVerification && { pendingPlanVerification: { plan: initialMsg.message.planContent!, verificationStarted: false, verificationCompleted: false, }, }), } }) // Create file history snapshot for code rewind if (fileHistoryEnabled()) { void fileHistoryMakeSnapshot( (updater: (prev: FileHistoryState) => FileHistoryState) => { setAppState(prev => ({ ...prev, fileHistory: updater(prev.fileHistory), })) }, initialMsg.message.uuid, ) } // Ensure SessionStart hook context is available before the first API // call. onSubmit calls this internally but the onQuery path below // bypasses onSubmit — hoist here so both paths see hook messages. await awaitPendingHooks() // Route all initial prompts through onSubmit to ensure UserPromptSubmit hooks fire // TODO: Simplify by always routing through onSubmit once it supports // ContentBlockParam arrays (images) as input const content = initialMsg.message.message.content // Route all string content through onSubmit to ensure hooks fire // For complex content (images, etc.), fall back to direct onQuery // Plan messages bypass onSubmit to preserve planContent metadata for rendering if (typeof content === 'string' && !initialMsg.message.planContent) { // Route through onSubmit for proper processing including UserPromptSubmit hooks void onSubmit(content, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }) } else { // Plan messages or complex content (images, etc.) - send directly to model // Plan messages use onQuery to preserve planContent metadata for rendering // TODO: Once onSubmit supports ContentBlockParam arrays, remove this branch const newAbortController = createAbortController() setAbortController(newAbortController) void onQuery( [initialMsg.message], newAbortController, true, // shouldQuery [], // additionalAllowedTools mainLoopModel, ) } // Reset ref after a delay to allow new initial messages setTimeout( ref => { ref.current = false }, 100, initialMessageRef, ) } void processInitialMessage(pending) }, [ initialMessage, isLoading, setMessages, setAppState, onQuery, mainLoopModel, tools, ]) const onSubmit = useCallback( async ( input: string, helpers: PromptInputHelpers, speculationAccept?: { state: ActiveSpeculationState speculationSessionTimeSavedMs: number setAppState: SetAppState }, options?: { fromKeybinding?: boolean }, ) => { // Re-pin scroll to bottom on submit so the user always sees the new // exchange (matches OpenCode's auto-scroll behavior). repinScroll() // Resume loop mode if paused if (feature('PROACTIVE') || feature('KAIROS')) { proactiveModule?.resumeProactive() } // Handle immediate commands - these bypass the queue and execute right away // even while Claude is processing. Commands opt-in via `immediate: true`. // Commands triggered via keybindings are always treated as immediate. if (!speculationAccept && input.trim().startsWith('/')) { // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive // the pasted content, not the placeholder. The non-immediate path gets // this expansion later in handlePromptSubmit. const trimmedInput = expandPastedTextRefs(input, pastedContents).trim() const spaceIndex = trimmedInput.indexOf(' ') const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex) const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim() // Find matching command - treat as immediate if: // 1. Command has `immediate: true`, OR // 2. Command was triggered via keybinding (fromKeybinding option) const matchingCommand = commands.find( cmd => isCommandEnabled(cmd) && (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName), ) if (matchingCommand?.name === 'clear' && idleHintShownRef.current) { logEvent('tengu_idle_return_action', { action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, idleMinutes: Math.round( (Date.now() - lastQueryCompletionTimeRef.current) / 60_000, ), messageCount: messagesRef.current.length, totalInputTokens: getTotalInputTokens(), }) idleHintShownRef.current = false } const shouldTreatAsImmediate = queryGuard.isActive && (matchingCommand?.immediate || options?.fromKeybinding) if ( matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx' ) { // Only clear input if the submitted text matches what's in the prompt. // When a command keybinding fires, input is "/" but the actual // input value is the user's existing text - don't clear it in that case. if (input.trim() === inputValueRef.current.trim()) { setInputValue('') helpers.setCursorOffset(0) helpers.clearBuffer() setPastedContents({}) } const pastedTextRefs = parseReferences(input).filter( r => pastedContents[r.id]?.type === 'text', ) const pastedTextCount = pastedTextRefs.length const pastedTextBytes = pastedTextRefs.reduce( (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0, ) logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes }) logEvent('tengu_immediate_command_executed', { commandName: matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fromKeybinding: options?.fromKeybinding ?? false, }) // Execute the command directly const executeImmediateCommand = async (): Promise => { let doneWasCalled = false const onDone = ( result?: string, doneOptions?: { display?: CommandResultDisplay metaMessages?: string[] }, ): void => { doneWasCalled = true setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, }) const newMessages: MessageType[] = [] if (result && doneOptions?.display !== 'skip') { addNotification({ key: `immediate-${matchingCommand.name}`, text: result, priority: 'immediate', }) // In fullscreen the command just showed as a centered modal // pane — the notification above is enough feedback. Adding // "❯ /config" + "⎿ dismissed" to the transcript is clutter // (those messages are type:system subtype:local_command — // user-visible but NOT sent to the model, so skipping them // doesn't change model context). Outside fullscreen the // transcript entry stays so scrollback shows what ran. if (!isFullscreenEnvEnabled()) { newMessages.push( createCommandInputMessage( formatCommandInputTags( getCommandName(matchingCommand), commandArgs, ), ), createCommandInputMessage( `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}`, ), ) } } // Inject meta messages (model-visible, user-hidden) into the transcript if (doneOptions?.metaMessages?.length) { newMessages.push( ...doneOptions.metaMessages.map(content => createUserMessage({ content, isMeta: true }), ), ) } if (newMessages.length) { setMessages(prev => [...prev, ...newMessages]) } // Restore stashed prompt after local-jsx command completes. // The normal stash restoration path (below) is skipped because // local-jsx commands return early from onSubmit. if (stashedPrompt !== undefined) { setInputValue(stashedPrompt.text) helpers.setCursorOffset(stashedPrompt.cursorOffset) setPastedContents(stashedPrompt.pastedContents) setStashedPrompt(undefined) } } // Build context for the command (reuses existing getToolUseContext). // Read messages via ref to keep onSubmit stable across message // updates — matches the pattern at L2384/L2400/L2662 and avoids // pinning stale REPL render scopes in downstream closures. const context = getToolUseContext( messagesRef.current, [], createAbortController(), mainLoopModel, ) const mod = await matchingCommand.load() const jsx = await mod.call(onDone, context, commandArgs) // Skip if onDone already fired — prevents stuck isLocalJSXCommand // (see processSlashCommand.tsx local-jsx case for full mechanism). if (jsx && !doneWasCalled) { // shouldHidePromptInput: false keeps Notifications mounted // so the onDone result isn't lost setToolJSX({ jsx, shouldHidePromptInput: false, isLocalJSXCommand: true, }) } } void executeImmediateCommand() return // Always return early - don't add to history or queue } } // Remote mode: skip empty input early before any state mutations if (activeRemote.isRemoteMode && !input.trim()) { return } // Idle-return: prompt returning users to start fresh when the // conversation is large and the cache is cold. tengu_willow_mode // controls treatment: "dialog" (blocking), "hint" (notification), "off". { const willowMode = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_willow_mode', 'off', ) const idleThresholdMin = Number( process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75, ) const tokenThreshold = Number( process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, ) if ( willowMode !== 'off' && !getGlobalConfig().idleReturnDismissed && !skipIdleCheckRef.current && !speculationAccept && !input.trim().startsWith('/') && lastQueryCompletionTimeRef.current > 0 && getTotalInputTokens() >= tokenThreshold ) { const idleMs = Date.now() - lastQueryCompletionTimeRef.current const idleMinutes = idleMs / 60_000 if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') { setIdleReturnPending({ input, idleMinutes }) setInputValue('') helpers.setCursorOffset(0) helpers.clearBuffer() return } } } // Add to history for direct user submissions. // Queued command processing (executeQueuedInput) doesn't call onSubmit, // so notifications and already-queued user input won't be added to history here. // Skip history for keybinding-triggered commands (user didn't type the command). if (!options?.fromKeybinding) { addToHistory({ display: speculationAccept ? input : prependModeCharacterToInput(input, inputMode), pastedContents: speculationAccept ? {} : pastedContents, }) // Add the just-submitted command to the front of the ghost-text // cache so it's suggested immediately (not after the 60s TTL). if (inputMode === 'bash') { prependToShellHistoryCache(input.trim()) } } // Restore stash if present, but NOT for slash commands or when loading. // - Slash commands (especially interactive ones like /model, /context) hide // the prompt and show a picker UI. Restoring the stash during a command would // place the text in a hidden input, and the user would lose it by typing the // next command. Instead, preserve the stash so it survives across command runs. // - When loading, the submitted input will be queued and handlePromptSubmit // will clear the input field (onInputChange('')), which would clobber the // restored stash. Defer restoration to after handlePromptSubmit (below). // Remote mode is exempt: it sends via WebSocket and returns early without // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly. // In both deferred cases, the stash is restored after await handlePromptSubmit. const isSlashCommand = !speculationAccept && input.trim().startsWith('/') // Submit runs "now" (not queued) when not already loading, or when // accepting speculation, or in remote mode (which sends via WS and // returns early without calling handlePromptSubmit). const submitsNow = !isLoading || speculationAccept || activeRemote.isRemoteMode if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) { setInputValue(stashedPrompt.text) helpers.setCursorOffset(stashedPrompt.cursorOffset) setPastedContents(stashedPrompt.pastedContents) setStashedPrompt(undefined) } else if (submitsNow) { if (!options?.fromKeybinding) { // Clear input when not loading or accepting speculation. // Preserve input for keybinding-triggered commands. setInputValue('') helpers.setCursorOffset(0) } setPastedContents({}) } if (submitsNow) { setInputMode('prompt') setIDESelection(undefined) setSubmitCount(_ => _ + 1) helpers.clearBuffer() tipPickedThisTurnRef.current = false // Show the placeholder in the same React batch as setInputValue(''). // Skip for slash/bash (they have their own echo), speculation and remote // mode (both setMessages directly with no gap to bridge). if ( !isSlashCommand && inputMode === 'prompt' && !speculationAccept && !activeRemote.isRemoteMode ) { setUserInputOnProcessing(input) // showSpinner includes userInputOnProcessing, so the spinner appears // on this render. Reset timing refs now (before queryGuard.reserve() // would) so elapsed time doesn't read as Date.now() - 0. The // isQueryActive transition above does the same reset — idempotent. resetTimingRefs() } // Increment prompt count for attribution tracking and save snapshot // The snapshot persists promptCount so it survives compaction if (feature('COMMIT_ATTRIBUTION')) { setAppState(prev => ({ ...prev, attribution: incrementPromptCount(prev.attribution, snapshot => { void recordAttributionSnapshot(snapshot).catch(error => { logForDebugging( `Attribution: Failed to save snapshot: ${error}`, ) }) }), })) } } // Handle speculation acceptance if (speculationAccept) { const { queryRequired } = await handleSpeculationAccept( speculationAccept.state, speculationAccept.speculationSessionTimeSavedMs, speculationAccept.setAppState, input, { setMessages, readFileState, cwd: getOriginalCwd(), }, ) if (queryRequired) { const newAbortController = createAbortController() setAbortController(newAbortController) void onQuery([], newAbortController, true, [], mainLoopModel) } return } // Remote mode: send input via stream-json instead of local query. // Permission requests from the remote are bridged into toolUseConfirmQueue // and rendered using the standard PermissionRequest component. // // local-jsx slash commands (e.g. /agents, /config) render UI in THIS // process — they have no remote equivalent. Let those fall through to // handlePromptSubmit so they execute locally. Prompt commands and // plain text go to the remote. if ( activeRemote.isRemoteMode && !( isSlashCommand && commands.find(c => { const name = input.trim().slice(1).split(/\s/)[0] return ( isCommandEnabled(c) && (c.name === name || c.aliases?.includes(name!) || getCommandName(c) === name) ) })?.type === 'local-jsx' ) ) { // Build content blocks when there are pasted attachments (images) const pastedValues = Object.values(pastedContents) const imageContents = pastedValues.filter(c => c.type === 'image') const imagePasteIds = imageContents.length > 0 ? imageContents.map(c => c.id) : undefined let messageContent: string | ContentBlockParam[] = input.trim() let remoteContent: RemoteMessageContent = input.trim() if (pastedValues.length > 0) { const contentBlocks: ContentBlockParam[] = [] const remoteBlocks: Array<{ type: string; [key: string]: unknown }> = [] const trimmedInput = input.trim() if (trimmedInput) { contentBlocks.push({ type: 'text', text: trimmedInput }) remoteBlocks.push({ type: 'text', text: trimmedInput }) } for (const pasted of pastedValues) { if (pasted.type === 'image') { const source = { type: 'base64' as const, media_type: (pasted.mediaType ?? 'image/png') as | 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: pasted.content, } contentBlocks.push({ type: 'image', source }) remoteBlocks.push({ type: 'image', source }) } else { contentBlocks.push({ type: 'text', text: pasted.content }) remoteBlocks.push({ type: 'text', text: pasted.content }) } } messageContent = contentBlocks remoteContent = remoteBlocks } // Create and add user message to UI // Note: empty input already handled by early return above const userMessage = createUserMessage({ content: messageContent, imagePasteIds, }) setMessages(prev => [...prev, userMessage]) // Send to remote session await activeRemote.sendMessage(remoteContent, { uuid: userMessage.uuid, }) return } // Ensure SessionStart hook context is available before the first API call. await awaitPendingHooks() await handlePromptSubmit({ input, helpers, queryGuard, isExternalLoading, mode: inputMode, commands, onInputChange: setInputValue, setPastedContents, setToolJSX, getToolUseContext, messages: messagesRef.current, mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, abortController, onQuery, setAppState, querySource: getQuerySourceForREPL(), onBeforeQuery, canUseTool, addNotification, setMessages, // Read via ref so streamMode can be dropped from onSubmit deps — // handlePromptSubmit only uses it for debug log + telemetry event. streamMode: streamModeRef.current, hasInterruptibleToolInProgress: hasInterruptibleToolInProgressRef.current, }) // Restore stash that was deferred above. Two cases: // - Slash command: handlePromptSubmit awaited the full command execution // (including interactive pickers). Restoring now places the stash back in // the visible input. // - Loading (queued): handlePromptSubmit enqueued + cleared input, then // returned quickly. Restoring now places the stash back after the clear. if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) { setInputValue(stashedPrompt.text) helpers.setCursorOffset(stashedPrompt.cursorOffset) setPastedContents(stashedPrompt.pastedContents) setStashedPrompt(undefined) } }, [ queryGuard, // isLoading is read at the !isLoading checks above for input-clearing // and submitCount gating. It's derived from isQueryActive || isExternalLoading, // so including it here ensures the closure captures the fresh value. isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext, // messages is read via messagesRef.current inside the callback to // keep onSubmit stable across message updates (see L2384/L2400/L2662). // Without this, each setMessages call (~30× per turn) recreates // onSubmit, pinning the REPL render scope (1776B) + that render's // messages array in downstream closures (PromptInput, handleAutoRunIssue). // Heap analysis showed ~9 REPL scopes and ~15 messages array versions // accumulating after #20174/#20175, all traced to this dep. mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll, ], ) // Callback for when user submits input while viewing a teammate's transcript const onAgentSubmit = useCallback( async ( input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers, ) => { if (isLocalAgentTask(task)) { appendMessageToLocalAgent( task.id, createUserMessage({ content: input }), setAppState, ) if (task.status === 'running') { queuePendingMessage(task.id, input, setAppState) } else { void resumeAgentBackground({ agentId: task.id, prompt: input, toolUseContext: getToolUseContext( messagesRef.current, [], new AbortController(), mainLoopModel, ), canUseTool, }).catch(err => { logForDebugging( `resumeAgentBackground failed: ${errorMessage(err)}`, ) addNotification({ key: `resume-agent-failed-${task.id}`, jsx: ( Failed to resume agent: {errorMessage(err)} ), priority: 'low', }) }) } } else { injectUserMessageToTeammate(task.id, input, setAppState) } setInputValue('') helpers.setCursorOffset(0) helpers.clearBuffer() }, [ setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification, ], ) // Handlers for auto-run /issue or /good-claude (defined after onSubmit) const handleAutoRunIssue = useCallback(() => { const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue' setAutoRunIssueReason(null) // Clear the state onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }).catch(err => { logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`) }) }, [onSubmit, autoRunIssueReason]) const handleCancelAutoRunIssue = useCallback(() => { setAutoRunIssueReason(null) }, []) // Handler for when user presses 1 on survey thanks screen to share details const handleSurveyRequestFeedback = useCallback(() => { const command = "external" === 'ant' ? '/issue' : '/feedback' onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }).catch(err => { logForDebugging( `Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`, ) }) }, [onSubmit]) // onSubmit is unstable (deps include `messages` which changes every turn). // `handleOpenRateLimitOptions` is prop-drilled to every MessageRow, and each // MessageRow fiber pins the closure (and transitively the entire REPL render // scope, ~1.8KB) at mount time. Using a ref keeps this callback stable so // old REPL scopes can be GC'd — saves ~35MB over a 1000-turn session. const onSubmitRef = useRef(onSubmit) onSubmitRef.current = onSubmit const handleOpenRateLimitOptions = useCallback(() => { void onSubmitRef.current('/rate-limit-options', { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }) }, []) const handleExit = useCallback(async () => { setIsExiting(true) // In bg sessions, always detach instead of kill — even when a worktree is // active. Without this guard, the worktree branch below short-circuits into // ExitFlow (which calls gracefulShutdown) before exit.tsx is ever loaded. if (feature('BG_SESSIONS') && isBgSession()) { spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }) setIsExiting(false) return } const showWorktree = getCurrentWorktreeSession() !== null if (showWorktree) { setExitFlow( {}} onCancel={() => { setExitFlow(null) setIsExiting(false) }} />, ) return } const exitMod = await exit.load() const exitFlowResult = await exitMod.call(() => {}) setExitFlow(exitFlowResult) // If call() returned without killing the process (bg session detach), // clear isExiting so the UI is usable on reattach. No-op on the normal // path — gracefulShutdown's process.exit() means we never get here. if (exitFlowResult === null) { setIsExiting(false) } }, []) const handleShowMessageSelector = useCallback(() => { setIsMessageSelectorVisible(prev => !prev) }, []) // Rewind conversation state to just before `message`: slice messages, // reset conversation ID, microcompact state, permission mode, prompt suggestion. // Does NOT touch the prompt input. Index is computed from messagesRef (always // fresh via the setMessages wrapper) so callers don't need to worry about // stale closures. const rewindConversationTo = useCallback( (message: UserMessage) => { const prev = messagesRef.current const messageIndex = prev.lastIndexOf(message) if (messageIndex === -1) return logEvent('tengu_conversation_rewind', { preRewindMessageCount: prev.length, postRewindMessageCount: messageIndex, messagesRemoved: prev.length - messageIndex, rewindToMessageIndex: messageIndex, }) setMessages(prev.slice(0, messageIndex)) // Careful, this has to happen after setMessages setConversationId(randomUUID()) // Reset cached microcompact state so stale pinned cache edits // don't reference tool_use_ids from truncated messages resetMicrocompactState() if (feature('CONTEXT_COLLAPSE')) { // Rewind truncates the REPL array. Commits whose archived span // was past the rewind point can't be projected anymore // (projectView silently skips them) but the staged queue and ID // maps reference stale uuids. Simplest safe reset: drop // everything. The ctx-agent will re-stage on the next // threshold crossing. /* eslint-disable @typescript-eslint/no-require-imports */ ;( require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') ).resetContextCollapse() /* eslint-enable @typescript-eslint/no-require-imports */ } // Restore state from the message we're rewinding to setAppState(prev => ({ ...prev, // Restore permission mode from the message toolPermissionContext: message.permissionMode && prev.toolPermissionContext.mode !== message.permissionMode ? { ...prev.toolPermissionContext, mode: message.permissionMode, } : prev.toolPermissionContext, // Clear stale prompt suggestion from previous conversation state promptSuggestion: { text: null, promptId: null, shownAt: 0, acceptedAt: 0, generationRequestId: null, }, })) }, [setMessages, setAppState], ) // Synchronous rewind + input population. Used directly by auto-restore on // interrupt (so React batches with the abort's setMessages → single render, // no flicker). MessageSelector wraps this in setImmediate via handleRestoreMessage. const restoreMessageSync = useCallback( (message: UserMessage) => { rewindConversationTo(message) const r = textForResubmit(message) if (r) { setInputValue(r.text) setInputMode(r.mode) } // Restore pasted images if ( Array.isArray(message.message.content) && message.message.content.some(block => block.type === 'image') ) { const imageBlocks: Array = message.message.content.filter(block => block.type === 'image') if (imageBlocks.length > 0) { const newPastedContents: Record = {} imageBlocks.forEach((block, index) => { if (block.source.type === 'base64') { const id = message.imagePasteIds?.[index] ?? index + 1 newPastedContents[id] = { id, type: 'image', content: block.source.data, mediaType: block.source.media_type, } } }) setPastedContents(newPastedContents) } } }, [rewindConversationTo, setInputValue], ) restoreMessageSyncRef.current = restoreMessageSync // MessageSelector path: defer via setImmediate so the "Interrupted" message // renders to static output before rewind — otherwise it remains vestigial // at the top of the screen. const handleRestoreMessage = useCallback( async (message: UserMessage) => { setImmediate( (restore, message) => restore(message), restoreMessageSync, message, ) }, [restoreMessageSync], ) // Not memoized — hook stores caps via ref, reads latest closure at dispatch. // 24-char prefix: deriveUUID preserves first 24, renderable uuid prefix-matches raw source. const findRawIndex = (uuid: string) => { const prefix = uuid.slice(0, 24) return messages.findIndex(m => m.uuid.slice(0, 24) === prefix) } const messageActionCaps: MessageActionCaps = { copy: text => // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). void setClipboard(text).then(raw => { if (raw) process.stdout.write(raw) addNotification({ // Same key as text-selection copy — repeated copies replace toast, don't queue. key: 'selection-copied', text: 'copied', color: 'success', priority: 'immediate', timeoutMs: 2000, }) }), edit: async msg => { // Same skip-confirm check as /rewind: lossless → direct, else confirm dialog. const rawIdx = findRawIndex(msg.uuid) const raw = rawIdx >= 0 ? messages[rawIdx] : undefined if (!raw || !selectableUserMessagesFilter(raw)) return const noFileChanges = !(await fileHistoryHasAnyChanges( fileHistory, raw.uuid, )) const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx) if (noFileChanges && onlySynthetic) { // rewindConversationTo's setMessages races stream appends — cancel first (idempotent). onCancel() // handleRestoreMessage also restores pasted images. void handleRestoreMessage(raw) } else { // Dialog path: onPreRestore (= onCancel) fires when user CONFIRMS, not on nevermind. setMessageSelectorPreselect(raw) setIsMessageSelectorVisible(true) } }, } const { enter: enterMessageActions, handlers: messageActionHandlers } = useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps) async function onInit() { // Always verify API key on startup, so we can show the user an error in the // bottom right corner of the screen if the API key is invalid. void reverify() // Populate readFileState with CLAUDE.md files at startup const memoryFiles = await getMemoryFiles() if (memoryFiles.length > 0) { const fileList = memoryFiles .map( f => ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`, ) .join('\n') logForDebugging( `Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`, ) } else { logForDebugging('No CLAUDE.md/rules files found') } for (const file of memoryFiles) { // When the injected content doesn't match disk (stripped HTML comments, // stripped frontmatter, MEMORY.md truncation), cache the RAW disk bytes // with isPartialView so Edit/Write require a real Read first while // getChangedFiles + nested_memory dedup still work. readFileState.current.set(file.path, { content: file.contentDiffersFromDisk ? (file.rawContent ?? file.content) : file.content, timestamp: Date.now(), offset: undefined, limit: undefined, isPartialView: file.contentDiffersFromDisk, }) } // Initial message handling is done via the initialMessage effect } // Register cost summary tracker useCostSummary(useFpsMetrics()) // Record transcripts locally, for debugging and conversation recovery // Don't record conversation if we only have initial messages; optimizes // the case where user resumes a conversation then quites before doing // anything else useLogMessages(messages, messages.length === initialMessages?.length) // REPL Bridge: replicate user/assistant messages to the bridge session // for remote access via claude.ai. No-op in external builds or when not enabled. const { sendBridgeResult } = useReplBridge( messages, setMessages, abortControllerRef, commands, mainLoopModel, ) sendBridgeResultRef.current = sendBridgeResult useAfterFirstRender() // Track prompt queue usage for analytics. Fire once per transition from // empty to non-empty, not on every length change -- otherwise a render loop // (concurrent onQuery thrashing, etc.) spams saveGlobalConfig, which hits // ELOCKED under concurrent sessions and falls back to unlocked writes. // That write storm is the primary trigger for ~/.claude.json corruption // (GH #3117). const hasCountedQueueUseRef = useRef(false) useEffect(() => { if (queuedCommands.length < 1) { hasCountedQueueUseRef.current = false return } if (hasCountedQueueUseRef.current) return hasCountedQueueUseRef.current = true saveGlobalConfig(current => ({ ...current, promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1, })) }, [queuedCommands.length]) // Process queued commands when query completes and queue has items const executeQueuedInput = useCallback( async (queuedCommands: QueuedCommand[]) => { await handlePromptSubmit({ helpers: { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }, queryGuard, commands, onInputChange: () => {}, setPastedContents: () => {}, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, setAbortController, onQuery, setAppState, querySource: getQuerySourceForREPL(), onBeforeQuery, canUseTool, addNotification, setMessages, queuedCommands, }) }, [ queryGuard, commands, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, canUseTool, setAbortController, onQuery, addNotification, setAppState, onBeforeQuery, ], ) useQueueProcessor({ executeQueuedInput, hasActiveLocalJsxUI: isShowingLocalJSXCommand, queryGuard, }) // We'll use the global lastInteractionTime from state.ts // Update last interaction time when input changes. // Must be immediate because useEffect runs after the Ink render cycle flush. useEffect(() => { activityManager.recordUserActivity() updateLastInteractionTime(true) }, [inputValue, submitCount]) useEffect(() => { if (submitCount === 1) { startBackgroundHousekeeping() } }, [submitCount]) // Show notification when Claude is done responding and user is idle useEffect(() => { // Don't set up notification if Claude is busy if (isLoading) return // Only enable notifications after the first new interaction in this session if (submitCount === 0) return // No query has completed yet if (lastQueryCompletionTime === 0) return // Set timeout to check idle state const timer = setTimeout( ( lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal, ) => { // Check if user has interacted since the response ended const lastUserInteraction = getLastInteractionTime() if (lastUserInteraction > lastQueryCompletionTime) { // User has interacted since Claude finished - they're not idle, don't notify return } // User hasn't interacted since response ended, check other conditions const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime if ( !isLoading && !toolJSX && // Use ref to get current dialog state, avoiding stale closure focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs ) { void sendNotification( { message: 'Claude is waiting for your input', notificationType: 'idle_prompt', }, terminal, ) } }, getGlobalConfig().messageIdleNotifThresholdMs, lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal, ) return () => clearTimeout(timer) }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]) // Idle-return hint: show notification when idle threshold is exceeded. // Timer fires after the configured idle period; notification persists until // dismissed or the user submits. useEffect(() => { if (lastQueryCompletionTime === 0) return if (isLoading) return const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_willow_mode', 'off', ) if (willowMode !== 'hint' && willowMode !== 'hint_v2') return if (getGlobalConfig().idleReturnDismissed) return const tokenThreshold = Number( process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, ) if (getTotalInputTokens() < tokenThreshold) return const idleThresholdMs = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000 const elapsed = Date.now() - lastQueryCompletionTime const remaining = idleThresholdMs - elapsed const timer = setTimeout( (lqct, addNotif, msgsRef, mode, hintRef) => { if (msgsRef.current.length === 0) return const totalTokens = getTotalInputTokens() const formattedTokens = formatTokens(totalTokens) const idleMinutes = (Date.now() - lqct) / 60_000 addNotif({ key: 'idle-return-hint', jsx: mode === 'hint_v2' ? ( <> new task? /clear to save {formattedTokens} tokens ) : ( new task? /clear to save {formattedTokens} tokens ), priority: 'medium', // Persist until submit — the hint fires at T+75min idle, user may // not return for hours. removeNotification in useEffect cleanup // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days). timeoutMs: 0x7fffffff, }) hintRef.current = mode logEvent('tengu_idle_return_action', { action: 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, variant: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, idleMinutes: Math.round(idleMinutes), messageCount: msgsRef.current.length, totalInputTokens: totalTokens, }) }, Math.max(0, remaining), lastQueryCompletionTime, addNotification, messagesRef, willowMode, idleHintShownRef, ) return () => { clearTimeout(timer) removeNotification('idle-return-hint') idleHintShownRef.current = false } }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]) // Submits incoming prompts from teammate messages or tasks mode as new turns // Returns true if submission succeeded, false if a query is already running const handleIncomingPrompt = useCallback( (content: string, options?: { isMeta?: boolean }): boolean => { if (queryGuard.isActive) return false // Defer to user-queued commands — user input always takes priority // over system messages (teammate messages, task list items, etc.) // Read from the module-level store at call time (not the render-time // snapshot) to avoid a stale closure — this callback's deps don't // include the queue. if ( getCommandQueue().some( cmd => cmd.mode === 'prompt' || cmd.mode === 'bash', ) ) { return false } const newAbortController = createAbortController() setAbortController(newAbortController) // Create a user message with the formatted content (includes XML wrapper) const userMessage = createUserMessage({ content, isMeta: options?.isMeta ? true : undefined, }) void onQuery([userMessage], newAbortController, true, [], mainLoopModel) return true }, [onQuery, mainLoopModel, store], ) // Voice input integration (VOICE_MODE builds only) const voice = feature('VOICE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef }) : { stripTrailing: () => 0, handleKeyEvent: () => {}, resetAnchor: () => {}, interimRange: null, } useInboxPoller({ enabled: isAgentSwarmsEnabled(), isLoading, focusedInputDialog, onSubmitMessage: handleIncomingPrompt, }) useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt }) useFeishuBridge({ messages, isLoading }) // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) if (feature('AGENT_TRIGGERS')) { // Assistant mode bypasses the isLoading gate (the proactive tick → // Sleep → tick loop would otherwise starve the scheduler). // kairosEnabled is set once in initialState (main.tsx) and never mutated — no // subscription needed. The tengu_kairos_cron runtime gate is checked inside // useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic // condition would break rules-of-hooks. const assistantMode = store.getState().kairosEnabled // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useScheduledTasks!({ isLoading, assistantMode, setMessages }) } // Note: Permission polling is now handled by useInboxPoller // - Workers receive permission responses via mailbox messages // - Leaders receive permission requests via mailbox messages if ("external" === 'ant') { // Tasks mode: watch for tasks and auto-process them // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds useTaskListWatcher({ taskListId, isLoading, onSubmitTask: handleIncomingPrompt, }) // Loop mode: auto-tick when enabled (via /job command) // eslint-disable-next-line react-hooks/rules-of-hooks // biome-ignore lint/correctness/useHookAtTopLevel: conditional for dead code elimination in external builds useProactive?.({ // Suppress ticks while an initial message is pending — the initial // message will be processed asynchronously and a premature tick would // race with it, causing concurrent-query enqueue of expanded skill text. isLoading: isLoading || initialMessage !== null, queuedCommandsLength: queuedCommands.length, hasActiveLocalJsxUI: isShowingLocalJSXCommand, isInPlanMode: toolPermissionContext.mode === 'plan', onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }), onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }), }) } // Abort the current operation when a 'now' priority message arrives // (e.g. from a chat UI client via UDS). useEffect(() => { if (queuedCommands.some(cmd => cmd.priority === 'now')) { abortControllerRef.current?.abort('interrupt') } }, [queuedCommands]) // Initial load useEffect(() => { void onInit() // Cleanup on unmount return () => { void diagnosticTracker.shutdown() } // TODO: fix this // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Listen for suspend/resume events const { internal_eventEmitter } = useStdin() const [remountKey, setRemountKey] = useState(0) useEffect(() => { const handleSuspend = () => { // Print suspension instructions process.stdout.write( `\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`, ) } const handleResume = () => { // Force complete component tree replacement instead of terminal clear // Ink now handles line count reset internally on SIGCONT setRemountKey(prev => prev + 1) } internal_eventEmitter?.on('suspend', handleSuspend) internal_eventEmitter?.on('resume', handleResume) return () => { internal_eventEmitter?.off('suspend', handleSuspend) internal_eventEmitter?.off('resume', handleResume) } }, [internal_eventEmitter]) // Derive stop hook spinner suffix from messages state const stopHookSpinnerSuffix = useMemo(() => { if (!isLoading) return null // Find stop hook progress messages const progressMsgs = messages.filter( (m): m is ProgressMessage => m.type === 'progress' && m.data.type === 'hook_progress' && (m.data.hookEvent === 'Stop' || m.data.hookEvent === 'SubagentStop'), ) if (progressMsgs.length === 0) return null // Get the most recent stop hook execution const currentToolUseID = progressMsgs.at(-1)?.toolUseID if (!currentToolUseID) return null // Check if there's already a summary message for this execution (hooks completed) const hasSummaryForCurrentExecution = messages.some( m => m.type === 'system' && m.subtype === 'stop_hook_summary' && m.toolUseID === currentToolUseID, ) if (hasSummaryForCurrentExecution) return null const currentHooks = progressMsgs.filter( p => p.toolUseID === currentToolUseID, ) const total = currentHooks.length // Count completed hooks const completedCount = count(messages, m => { if (m.type !== 'attachment') return false const attachment = m.attachment return ( 'hookEvent' in attachment && (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') && 'toolUseID' in attachment && attachment.toolUseID === currentToolUseID ) }) // Check if any hook has a custom status message const customMessage = currentHooks.find(p => p.data.statusMessage)?.data .statusMessage if (customMessage) { // Use custom message with progress counter if multiple hooks return total === 1 ? `${customMessage}…` : `${customMessage}… ${completedCount}/${total}` } // Fall back to default behavior const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop' if ("external" === 'ant') { const cmd = currentHooks[completedCount]?.data.command const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '' return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}` } return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}` }, [messages, isLoading]) // Callback to capture frozen state when entering transcript mode const handleEnterTranscript = useCallback(() => { setFrozenTranscriptState({ messagesLength: messages.length, streamingToolUsesLength: streamingToolUses.length, }) }, [messages.length, streamingToolUses.length]) // Callback to clear frozen state when exiting transcript mode const handleExitTranscript = useCallback(() => { setFrozenTranscriptState(null) }, []) // Props for GlobalKeybindingHandlers component (rendered inside KeybindingSetup) const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll // Transcript search state. Hooks must be unconditional so they live here // (not inside the `if (screen === 'transcript')` branch below); isActive // gates the useInput. Query persists across bar open/close so n/N keep // working after Enter dismisses the bar (less semantics). const jumpRef = useRef(null) const [searchOpen, setSearchOpen] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [searchCount, setSearchCount] = useState(0) const [searchCurrent, setSearchCurrent] = useState(0) const onSearchMatchesChange = useCallback( (count: number, current: number) => { setSearchCount(count) setSearchCurrent(current) }, [], ) useInput( (input, key, event) => { if (key.ctrl || key.meta) return // No Esc handling here — less has no navigating mode. Search state // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit // (ungated). Highlights clear on exit via the screen-change effect. if (input === '/') { // Capture scrollTop NOW — typing is a preview, 0-matches snaps // back here. Synchronous ref write, fires before the bar's // mount-effect calls setSearchQuery. jumpRef.current?.setAnchor() setSearchOpen(true) event.stopImmediatePropagation() return } // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each // repeat is a step (n isn't idempotent like g). const c = input[0] if ( (c === 'n' || c === 'N') && input === c.repeat(input.length) && searchCount > 0 ) { const fn = c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch if (fn) for (let i = 0; i < input.length; i++) fn() event.stopImmediatePropagation() } }, // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ // kills it, so !dumpMode — after [ there's nothing to jump in. { isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode, }, ) const { setQuery: setHighlight, scanElement, setPositions, } = useSearchHighlight() // Resize → abort search. Positions are (msg, query, WIDTH)-keyed — // cached positions are stale after a width change (new layout, new // wrapping). Clearing searchQuery triggers VML's setSearchQuery('') // which clears positionsCache + setPositions(null). Bar closes. // User hits / again → fresh everything. const transcriptCols = useTerminalSize().columns const prevColsRef = React.useRef(transcriptCols) React.useEffect(() => { if (prevColsRef.current !== transcriptCols) { prevColsRef.current = transcriptCols if (searchQuery || searchOpen) { setSearchOpen(false) setSearchQuery('') setSearchCount(0) setSearchCurrent(0) jumpRef.current?.disarmSearch() setHighlight('') } } }, [transcriptCols, searchQuery, searchOpen, setHighlight]) // Transcript escape hatches. Bare letters in modal context (no prompt // competing for input) — same class as g/G/j/k in ScrollKeybindingHandler. useInput( (input, key, event) => { if (key.ctrl || key.meta) return if (input === 'q') { // less: q quits the pager. ctrl+o toggles; q is the lineage exit. handleExitTranscript() event.stopImmediatePropagation() return } if (input === '[' && !dumpMode) { // Force dump-to-scrollback. Also expand + uncap — no point dumping // a subset. Terminal/tmux cmd-F can now find anything. Guard here // (not in isActive) so v still works post-[ — dump-mode footer at // ~4898 wires editorStatus, confirming v is meant to stay live. setDumpMode(true) setShowAllInTranscript(true) event.stopImmediatePropagation() } else if (input === 'v') { // less-style: v opens the file in $VISUAL/$EDITOR. Render the full // transcript (same path /export uses), write to tmp, hand off. // openFileInExternalEditor handles alt-screen suspend/resume for // terminal editors; GUI editors spawn detached. event.stopImmediatePropagation() // Drop double-taps: the render is async and a second press before it // completes would run a second parallel render (double memory, two // tempfiles, two editor spawns). editorGenRef only guards // transcript-exit staleness, not same-session concurrency. if (editorRenderingRef.current) return editorRenderingRef.current = true // Capture generation + make a staleness-aware setter. Each write // checks gen (transcript exit bumps it → late writes from the // async render go silent). const gen = editorGenRef.current const setStatus = (s: string): void => { if (gen !== editorGenRef.current) return clearTimeout(editorTimerRef.current) setEditorStatus(s) } setStatus(`rendering ${deferredMessages.length} messages…`) void (async () => { try { // Width = terminal minus vim's line-number gutter (4 digits + // space + slack). Floor at 80. PassThrough has no .columns so // without this Ink defaults to 80. Trailing-space strip: right- // aligned timestamps still leave a flexbox spacer run at EOL. // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep const w = Math.max(80, (process.stdout.columns ?? 80) - 6) const raw = await renderMessagesToPlainText( deferredMessages, tools, w, ) const text = raw.replace(/[ \t]+$/gm, '') const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`) await writeFile(path, text) const opened = openFileInExternalEditor(path) setStatus( opened ? `opening ${path}` : `wrote ${path} · no $VISUAL/$EDITOR set`, ) } catch (e) { setStatus( `render failed: ${e instanceof Error ? e.message : String(e)}`, ) } editorRenderingRef.current = false if (gen !== editorGenRef.current) return editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus) })() } }, // !searchOpen: typing 'v' or '[' in the search bar is search input, not // a command. No !dumpMode here — v should work after [ (the [ handler // guards itself inline). { isActive: screen === 'transcript' && virtualScrollActive && !searchOpen }, ) // Fresh `less` per transcript entry. Prevents stale highlights matching // unrelated normal-mode text (overlay is alt-screen-global) and avoids // surprise n/N on re-entry. Same exit resets [ dump mode — each ctrl+o // entry is a fresh instance. const inTranscript = screen === 'transcript' && virtualScrollActive useEffect(() => { if (!inTranscript) { setSearchQuery('') setSearchCount(0) setSearchCurrent(0) setSearchOpen(false) editorGenRef.current++ clearTimeout(editorTimerRef.current) setDumpMode(false) setEditorStatus('') } }, [inTranscript]) useEffect(() => { setHighlight(inTranscript ? searchQuery : '') // Clear the position-based CURRENT (yellow) overlay too. setHighlight // only clears the scan-based inverse. Without this, the yellow box // persists at its last screen coords after ctrl-c exits transcript. if (!inTranscript) setPositions(null) }, [inTranscript, searchQuery, setHighlight, setPositions]) const globalKeybindingProps = { screen, setScreen, showAllInTranscript, setShowAllInTranscript, messageCount: messages.length, onEnterTranscript: handleEnterTranscript, onExitTranscript: handleExitTranscript, virtualScrollActive, // Bar-open is a mode (owns keystrokes — j/k type, Esc cancels). // Navigating (query set, bar closed) is NOT — Esc exits transcript, // same as less q with highlights still visible. useSearchInput // doesn't stopPropagation, so without this gate transcript:exit // would fire on the same Esc that cancels the bar (child registers // first, fires first, bubbles). searchBarOpen: searchOpen, } // Use frozen lengths to slice arrays, avoiding memory overhead of cloning const transcriptMessages = frozenTranscriptState ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) : deferredMessages const transcriptStreamingToolUses = frozenTranscriptState ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) : streamingToolUses // Handle shift+down for teammate navigation and background task management. // Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open — // otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input. useBackgroundTaskNavigation({ onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true), }) // Auto-exit viewing mode when teammate completes or errors useTeammateViewAutoExit() if (screen === 'transcript') { // Virtual scroll replaces the 30-message cap: everything is scrollable // and memory is bounded by the viewport. Without it, wrapping transcript // in a ScrollBox would mount all messages (~250 MB on long sessions — // the exact problem), so the kill switch and non-fullscreen paths must // fall through to the legacy render: no alt screen, dump to terminal // scrollback, 30-cap + Ctrl+E. Reusing scrollRef is safe — normal-mode // and transcript-mode are mutually exclusive (this early return), so // only one ScrollBox is ever mounted at a time. const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined const transcriptMessagesElement = ( ) const transcriptToolJSX = toolJSX && ( {toolJSX.jsx} ) const transcriptReturn = ( {feature('VOICE_MODE') ? ( ) : null} {transcriptScrollRef ? ( // ScrollKeybindingHandler must mount before CancelRequestHandler so // ctrl+c-with-selection copies instead of cancelling the active task. // Its raw useInput handler only stops propagation when a selection // exists — without one, ctrl+c falls through to CancelRequestHandler. jumpRef.current?.disarmSearch()} /> ) : null} {transcriptScrollRef ? ( {transcriptMessagesElement} {transcriptToolJSX} } bottom={ searchOpen ? ( { // Enter — commit. 0-match guard: junk query shouldn't // persist (badge hidden, n/N dead anyway). setSearchQuery(searchCount > 0 ? q : '') setSearchOpen(false) // onCancel path: bar unmounts before its useEffect([query]) // can fire with ''. Without this, searchCount stays stale // (n guard at :4956 passes) and VML's matches[] too // (nextMatch walks the old array). Phantom nav, no // highlight. onExit (Enter, q non-empty) still commits. if (!q) { setSearchCount(0) setSearchCurrent(0) jumpRef.current?.setSearchQuery('') } }} onCancel={() => { // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired // with whatever was typed. searchQuery (REPL state) // is unchanged since / (onClose = commit, didn't run). // Two VML calls: '' restores anchor (0-match else- // branch), then searchQuery re-scans from anchor's // nearest. Both synchronous — one React batch. // setHighlight explicit: REPL's sync-effect dep is // searchQuery (unchanged), wouldn't re-fire. setSearchOpen(false) jumpRef.current?.setSearchQuery('') jumpRef.current?.setSearchQuery(searchQuery) setHighlight(searchQuery) }} setHighlight={setHighlight} /> ) : ( 0 ? { current: searchCurrent, count: searchCount } : undefined } /> ) } /> ) : ( <> {transcriptMessagesElement} {transcriptToolJSX} )} ) // The virtual-scroll branch (FullscreenLayout above) needs // 's constraint — without it, // ScrollBox's flexGrow has no ceiling, viewport = content height, // scrollTop pins at 0, and Ink's screen buffer sizes to the full // spacer (200×5k+ rows on long sessions). Same root type + props as // normal mode's wrap below so React reconciles and the alt buffer // stays entered across toggle. The 30-cap dump branch stays // unwrapped — it wants native terminal scrollback. if (transcriptScrollRef) { return ( {transcriptReturn} ) } return transcriptReturn } // Get viewed agent task (inlined from selectors for explicit data flow). // viewedAgentTask: teammate OR local_agent — drives the boolean checks // below. viewedTeammateTask: teammate-only narrowed, for teammate-specific // field access (inProgressToolUseIDs). const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined) // Bypass useDeferredValue when streaming text is showing so Messages renders // the final message in the same frame streaming text clears. Also bypass when // not loading — deferredMessages only matters during streaming (keeps input // responsive); after the turn ends, showing messages immediately prevents a // jitter gap where the spinner is gone but the answer hasn't appeared yet. // Only reducedMotion users keep the deferred path during loading. const usesSyncMessages = showStreamingText || !isLoading // When viewing an agent, never fall through to leader — empty until // bootstrap/stream fills. Closes the see-leader-type-agent footgun. const displayedMessages = viewedAgentTask ? (viewedAgentTask.messages ?? []) : usesSyncMessages ? messages : deferredMessages // Show the placeholder until the real user message appears in // displayedMessages. userInputOnProcessing stays set for the whole turn // (cleared in resetLoadingState); this length check hides it once // displayedMessages grows past the baseline captured at submit time. // Covers both gaps: before setMessages is called (processUserInput), and // while deferredMessages lags behind messages. Suppressed when viewing an // agent — displayedMessages is a different array there, and onAgentSubmit // doesn't use the placeholder anyway. const placeholderText = userInputOnProcessing && !viewedAgentTask && displayedMessages.length <= userInputBaselineRef.current ? userInputOnProcessing : undefined const toolPermissionOverlay = focusedInputDialog === 'tool-permission' ? ( setToolUseConfirmQueue(([_, ...tail]) => tail)} onReject={handleQueuedCommandOnCancel} toolUseConfirm={toolUseConfirmQueue[0]!} toolUseContext={getToolUseContext( messages, messages, abortController ?? createAbortController(), mainLoopModel, )} verbose={verbose} workerBadge={toolUseConfirmQueue[0]?.workerBadge} setStickyFooter={ isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined } /> ) : null // Standalone question overlay (decoupled from the permission system). // Reuses the AskUserQuestion choice UI but resolves questionService. const QuestionPromptBox = require('../components/question/QuestionPrompt.js').QuestionPrompt const questionOverlay = focusedInputDialog === 'question' && questionRequest ? ( setQuestionRequest(null)} /> ) : null // Narrow terminals: companion collapses to a one-liner that REPL stacks // on its own row (above input in fullscreen, below in scrollback) instead // of row-beside. Wide terminals keep the row layout with sprite on the right. const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE // Hide the sprite when PromptInput early-returns BackgroundTasksDialog. // The sprite sits as a row sibling of PromptInput, so the dialog's Pane // divider draws at useTerminalSize() width but only gets terminalWidth - // spriteWidth — divider stops short and dialog text wraps early. Don't // check footerSelection: pill FOCUS (arrow-down to tasks pill) must keep // the sprite visible so arrow-right can navigate to it. const companionVisible = !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog // In fullscreen, ALL local-jsx slash commands float in the modal slot — // FullscreenLayout wraps them in an absolute-positioned bottom-anchored // pane (▔ divider, ModalContext). Pane/Dialog inside detect the context // and skip their own top-level frame. Non-fullscreen keeps the inline // render paths below. Commands that used to route through bottom // (immediate: /model, /mcp, /btw, ...) and scrollable (non-immediate: // /config, /theme, /diff, ...) both go here now. const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null // at the root: everything below is inside its // . Handlers/contexts are zero-height so ScrollBox's // flexGrow in FullscreenLayout resolves against this Box. The transcript // early return above wraps its virtual-scroll branch the same way; only // the 30-cap dump branch stays unwrapped for native terminal scrollback. const mainReturn = ( {feature('VOICE_MODE') ? ( ) : null} {/* ScrollKeybindingHandler must mount before CancelRequestHandler so ctrl+c-with-selection copies instead of cancelling the active task. Its raw useInput handler only stops propagation when a selection exists — without one, ctrl+c falls through to CancelRequestHandler. PgUp/PgDn/wheel always scroll the transcript behind the modal — the modal's inner ScrollBox is not keyboard-driven. onScroll stays suppressed while a modal is showing so scroll doesn't stamp divider/pill state. */} {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? ( ) : null} ) : undefined } modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { setCursor(null) jumpToNew(scrollRef.current) }} scrollable={ <> {/* Hide the processing placeholder while a modal is showing — it would sit at the last visible transcript row right above the ▔ divider, showing "❯ /config" as redundant clutter (the modal IS the /config UI). Outside modals it stays so the user sees their input echoed while Claude processes. */} {!disabled && placeholderText && !centeredModal && ( )} {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && ( {toolJSX.jsx} )} {"external" === 'ant' && } {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && ( ) : null} {showSpinner && ( 0} leaderIsIdle={!isLoading} /> )} {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } {isFullscreenEnvEnabled() && } } bottom={ {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? ( ) : null} {permissionStickyFooter} {/* Immediate local-jsx commands (/btw, /sandbox, /assistant, /issue) render here, NOT inside scrollable. They stay mounted while the main conversation streams behind them, so ScrollBox relayouts on each new message would drag them around. bottom is flexShrink={0} outside the ScrollBox — it never moves. Non-immediate local-jsx (/diff, /status, /theme, ~40 others) stays in scrollable: the main loop is paused so no jiggle, and their tall content (DiffDetailView renders up to 400 lines with no internal scroll) needs the outer ScrollBox. */} {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered && ( {toolJSX.jsx} )} {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && ( )} {focusedInputDialog === 'sandbox-permission' && ( { const { allow, persistToSettings } = response const currentRequest = sandboxPermissionRequestQueue[0] if (!currentRequest) return const approvedHost = currentRequest.hostPattern.host if (persistToSettings) { const update = { type: 'addRules' as const, rules: [ { toolName: WEB_FETCH_TOOL_NAME, ruleContent: `domain:${approvedHost}`, }, ], behavior: (allow ? 'allow' : 'deny') as | 'allow' | 'deny', destination: 'localSettings' as const, } setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdate( prev.toolPermissionContext, update, ), })) persistPermissionUpdate(update) // Immediately update sandbox in-memory config to prevent race conditions // where pending requests slip through before settings change is detected SandboxManager.refreshConfig() } // Resolve ALL pending requests for the same host (not just the first one) // This handles the case where multiple parallel requests came in for the same domain setSandboxPermissionRequestQueue(queue => { queue .filter( item => item.hostPattern.host === approvedHost, ) .forEach(item => item.resolvePromise(allow)) return queue.filter( item => item.hostPattern.host !== approvedHost, ) }) // Clean up bridge subscriptions and cancel remote prompts // for this host since the local user already responded. const cleanups = sandboxBridgeCleanupRef.current.get(approvedHost) if (cleanups) { for (const fn of cleanups) { fn() } sandboxBridgeCleanupRef.current.delete(approvedHost) } }} /> )} {focusedInputDialog === 'prompt' && ( { const item = promptQueue[0] if (!item) return item.resolve({ prompt_response: item.request.prompt, selected: selectedKey, }) setPromptQueue(([, ...tail]) => tail) }} onAbort={() => { const item = promptQueue[0] if (!item) return item.reject(new Error('Prompt cancelled by user')) setPromptQueue(([, ...tail]) => tail) }} /> )} {/* Show pending indicator on worker while waiting for leader approval */} {pendingWorkerRequest && ( )} {/* Show pending indicator for sandbox permission on worker side */} {pendingSandboxRequest && ( )} {/* Worker sandbox permission requests from swarm workers */} {focusedInputDialog === 'worker-sandbox-permission' && ( { const { allow, persistToSettings } = response const currentRequest = workerSandboxPermissions.queue[0] if (!currentRequest) return const approvedHost = currentRequest.host // Send response via mailbox to the worker void sendSandboxPermissionResponseViaMailbox( currentRequest.workerName, currentRequest.requestId, approvedHost, allow, teamContext?.teamName, ) if (persistToSettings && allow) { const update = { type: 'addRules' as const, rules: [ { toolName: WEB_FETCH_TOOL_NAME, ruleContent: `domain:${approvedHost}`, }, ], behavior: 'allow' as const, destination: 'localSettings' as const, } setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdate( prev.toolPermissionContext, update, ), })) persistPermissionUpdate(update) SandboxManager.refreshConfig() } // Remove from queue setAppState(prev => ({ ...prev, workerSandboxPermissions: { ...prev.workerSandboxPermissions, queue: prev.workerSandboxPermissions.queue.slice(1), }, })) }} /> )} {focusedInputDialog === 'elicitation' && ( { const currentRequest = elicitation.queue[0] if (!currentRequest) return // Call respond callback to resolve Promise currentRequest.respond({ action, content }) // For URL accept, keep in queue for phase 2 const isUrlAccept = currentRequest.params.mode === 'url' && action === 'accept' if (!isUrlAccept) { setAppState(prev => ({ ...prev, elicitation: { queue: prev.elicitation.queue.slice(1), }, })) } }} onWaitingDismiss={action => { const currentRequest = elicitation.queue[0] // Remove from queue setAppState(prev => ({ ...prev, elicitation: { queue: prev.elicitation.queue.slice(1), }, })) currentRequest?.onWaitingDismiss?.(action) }} /> )} {focusedInputDialog === 'cost' && ( { setShowCostDialog(false) setHaveShownCostDialog(true) saveGlobalConfig(current => ({ ...current, hasAcknowledgedCostThreshold: true, })) logEvent('tengu_cost_threshold_acknowledged', {}) }} /> )} {focusedInputDialog === 'idle-return' && idleReturnPending && ( { const pending = idleReturnPending setIdleReturnPending(null) logEvent('tengu_idle_return_action', { action: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, idleMinutes: Math.round(pending.idleMinutes), messageCount: messagesRef.current.length, totalInputTokens: getTotalInputTokens(), }) if (action === 'dismiss') { setInputValue(pending.input) return } if (action === 'never') { saveGlobalConfig(current => { if (current.idleReturnDismissed) return current return { ...current, idleReturnDismissed: true } }) } if (action === 'clear') { const { clearConversation } = await import( '../commands/clear/conversation.js' ) await clearConversation({ setMessages, readFileState: readFileState.current, discoveredSkillNames: discoveredSkillNamesRef.current, loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, getAppState: () => store.getState(), setAppState, setConversationId, }) titleGenerationAttemptedRef.current = false setAutoGeneratedTitle(undefined) bashTools.current.clear() bashToolsProcessedIdx.current = 0 } skipIdleCheckRef.current = true void onSubmitRef.current(pending.input, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }) }} /> )} {focusedInputDialog === 'ide-onboarding' && ( setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} /> )} {"external" === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && ( { setShowModelSwitchCallout(false) if (selection === 'switch' && modelAlias) { setAppState(prev => ({ ...prev, mainLoopModel: modelAlias, mainLoopModelForSession: null, })) } }} /> )} {"external" === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && ( setShowUndercoverCallout(false)} /> )} {focusedInputDialog === 'effort-callout' && ( { setShowEffortCallout(false) if (selection !== 'dismiss') { setAppState(prev => ({ ...prev, effortValue: selection, })) } }} /> )} {focusedInputDialog === 'remote-callout' && ( { setAppState(prev => { if (!prev.showRemoteCallout) return prev return { ...prev, showRemoteCallout: false, ...(selection === 'enable' && { replBridgeEnabled: true, replBridgeExplicit: true, replBridgeOutboundOnly: false, }), } }) }} /> )} {exitFlow} {focusedInputDialog === 'plugin-hint' && hintRecommendation && ( )} {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && ( )} {focusedInputDialog === 'desktop-upsell' && ( setShowDesktopUpsellStartup(false)} /> )} {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && ( store.getState()} setConversationId={setConversationId} /> ) : null} {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && ( { const blurb = ultraplanLaunchPending.blurb setAppState(prev => prev.ultraplanLaunchPending ? { ...prev, ultraplanLaunchPending: undefined } : prev, ) if (choice === 'cancel') return // Command's onDone used display:'skip', so add the // echo here — gives immediate feedback before the // ~5s teleportToRemote resolves. setMessages(prev => [ ...prev, createCommandInputMessage( formatCommandInputTags('ultraplan', blurb), ), ]) const appendStdout = (msg: string) => setMessages(prev => [ ...prev, createCommandInputMessage( `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`, ), ]) // Defer the second message if a query is mid-turn // so it lands after the assistant reply, not // between the user's prompt and the reply. const appendWhenIdle = (msg: string) => { if (!queryGuard.isActive) { appendStdout(msg) return } const unsub = queryGuard.subscribe(() => { if (queryGuard.isActive) return unsub() // Skip if the user stopped ultraplan while we // were waiting — avoids a stale "Monitoring // " message for a session that's gone. if (!store.getState().ultraplanSessionUrl) return appendStdout(msg) }) } void launchUltraplan({ blurb, getAppState: () => store.getState(), setAppState, signal: createAbortController().signal, disconnectedBridge: opts?.disconnectedBridge, onSessionReady: appendWhenIdle, }) .then(appendStdout) .catch(logError) }} /> ) : null} {mrRender()} {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && ( <> {autoRunIssueReason && ( )} {postCompactSurvey.state !== 'closed' ? ( ) : memorySurvey.state !== 'closed' ? ( ) : ( )} {/* Frustration-triggered transcript sharing prompt */} {frustrationDetection.state !== 'closed' && ( {}} handleTranscriptSelect={ frustrationDetection.handleTranscriptSelect } inputValue={inputValue} setInputValue={setInputValue} /> )} {/* Skill improvement survey - appears when improvements detected (ant-only) */} {"external" === 'ant' && skillImprovementSurvey.suggestion && ( )} {showIssueFlagBanner && } { } )} {cursor && ( // inputValue is REPL state; typed text survives the round-trip. )} {focusedInputDialog === 'message-selector' && ( { await fileHistoryRewind( ( updater: (prev: FileHistoryState) => FileHistoryState, ) => { setAppState(prev => ({ ...prev, fileHistory: updater(prev.fileHistory), })) }, message.uuid, ) }} onSummarize={async ( message: UserMessage, feedback?: string, direction: PartialCompactDirection = 'from', ) => { // Project snipped messages so the compact model // doesn't summarize content that was intentionally removed. const compactMessages = getMessagesAfterCompactBoundary(messages) const messageIndex = compactMessages.indexOf(message) if (messageIndex === -1) { // Selected a snipped or pre-compact message that the // selector still shows (REPL keeps full history for // scrollback). Surface why nothing happened instead // of silently no-oping. setMessages(prev => [ ...prev, createSystemMessage( 'That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', 'warning', ), ]) return } const newAbortController = createAbortController() const context = getToolUseContext( compactMessages, [], newAbortController, mainLoopModel, ) const appState = context.getAppState() const defaultSysPrompt = await getSystemPrompt( context.options.tools, context.options.mainLoopModel, Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), ), context.options.mcpClients, ) const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition: undefined, toolUseContext: context, customSystemPrompt: context.options.customSystemPrompt, defaultSystemPrompt: defaultSysPrompt, appendSystemPrompt: context.options.appendSystemPrompt, }) const [userContext, systemContext] = await Promise.all([ getUserContext(), getSystemContext(), ]) const result = await partialCompactConversation( compactMessages, messageIndex, context, { systemPrompt, userContext, systemContext, toolUseContext: context, forkContextMessages: compactMessages, }, feedback, direction, ) const kept = result.messagesToKeep ?? [] const ordered = direction === 'up_to' ? [...result.summaryMessages, ...kept] : [...kept, ...result.summaryMessages] const postCompact = [ result.boundaryMarker, ...ordered, ...result.attachments, ...result.hookResults, ] // Fullscreen 'from' keeps scrollback; 'up_to' must not // (old[0] unchanged + grown array means incremental // useLogMessages path, so boundary never persisted). // Find by uuid since old is raw REPL history and snipped // entries can shift the projected messageIndex. if (isFullscreenEnvEnabled() && direction === 'from') { setMessages(old => { const rawIdx = old.findIndex( m => m.uuid === message.uuid, ) return [ ...old.slice(0, rawIdx === -1 ? 0 : rawIdx), ...postCompact, ] }) } else { setMessages(postCompact) } // Partial compact bypasses handleMessageFromStream — clear // the context-blocked flag so proactive ticks resume. if (feature('PROACTIVE') || feature('KAIROS')) { proactiveModule?.setContextBlocked(false) } setConversationId(randomUUID()) runPostCompactCleanup(context.options.querySource) if (direction === 'from') { const r = textForResubmit(message) if (r) { setInputValue(r.text) setInputMode(r.mode) } } // Show notification with ctrl+o hint const historyShortcut = getShortcutDisplay( 'app:toggleTranscript', 'Global', 'ctrl+o', ) addNotification({ key: 'summarize-ctrl-o-hint', text: `Conversation summarized (${historyShortcut} for history)`, priority: 'medium', timeoutMs: 8000, }) }} onRestoreMessage={handleRestoreMessage} onClose={() => { setIsMessageSelectorVisible(false) setMessageSelectorPreselect(undefined) }} /> )} {"external" === 'ant' && } {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? ( ) : null} } /> ) if (isFullscreenEnvEnabled()) { return ( {mainReturn} ) } return mainReturn }