import { c as _c } from "react-compiler-runtime"; // biome-ignore-all assist/source/organizeImports: internal-only import markers must not be reordered import { feature } from 'bun:bundle'; import { spawnSync } from 'child_process'; import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js'; import { parseTokenBudget } from '../utils/tokenBudget.js'; import { count } from '../utils/array.js'; import { dirname, join } from 'path'; import { tmpdir } from 'os'; import figures from 'figures'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler import { useInput } from '../ink.js'; import { useSearchInput } from '../hooks/useSearchInput.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'; import type { JumpHandle } from '../components/VirtualMessageList.js'; import { renderMessagesToPlainText } from '../utils/exportRenderer.js'; import { openFileInExternalEditor } from '../utils/editor.js'; import { writeFile } from 'fs/promises'; import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js'; import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'; import { CostThresholdDialog } from '../components/CostThresholdDialog.js'; import { IdleReturnDialog } from '../components/IdleReturnDialog.js'; import * as React from 'react'; import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, useLayoutEffect, type RefObject } from 'react'; import { useNotifications } from '../context/notifications.js'; import { sendNotification } from '../services/notifier.js'; import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js'; import { useTerminalNotification } from '../ink/useTerminalNotification.js'; import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'; import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration } from '../bootstrap/state.js'; import { asSessionId, asAgentId } from '../types/ids.js'; import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; import { consumeEarlyInput } from '../utils/earlyInput.js'; import { setMemberActive } from '../utils/swarm/teamHelpers.js'; import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'; import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'; import { getTeamName, getAgentName } from '../utils/teammate.js'; import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'; import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js'; import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'; import { useLogMessages } from '../hooks/useLogMessages.js'; import { useReplBridge } from '../hooks/useReplBridge.js'; import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js'; import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js'; import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../components/MessageSelector.js'; import { useIdeLogging } from '../hooks/useIdeLogging.js'; import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'; import { PromptDialog } from '../components/hooks/PromptDialog.js'; import type { PromptRequest, PromptResponse } from '../types/hooks.js'; import PromptInput from '../components/PromptInput/PromptInput.js'; import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'; import { useRemoteSession } from '../hooks/useRemoteSession.js'; import { useDirectConnect } from '../hooks/useDirectConnect.js'; import type { DirectConnectConfig } from '../server/directConnectManager.js'; import { useSSHSession } from '../hooks/useSSHSession.js'; import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; import type { SSHSession } from '../ssh/createSSHSession.js'; import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; import { useMoreRight } from '../moreright/useMoreRight.js'; import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; import { getSystemPrompt } from '../constants/prompts.js'; import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'; import { getSystemContext, getUserContext } from '../context.js'; import { getMemoryFiles } from '../utils/claudemd.js'; import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'; import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts } from '../cost-tracker.js'; import { useCostSummary } from '../costHook.js'; import { useFpsMetrics } from '../context/fpsMetrics.js'; import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'; import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'; import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences } from '../history.js'; import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'; import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'; import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'; import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'; import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'; import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; import { CancelRequestHandler } from '../hooks/useCancelRequest.js'; import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'; import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'; import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'; import { errorMessage } from '../utils/errors.js'; import { isHumanTurn } from '../utils/messagePredicates.js'; import { logError } from '../utils/log.js'; // Dead code elimination: conditional imports /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ stripTrailing: () => 0, handleKeyEvent: () => { }, resetAnchor: () => { } }); const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; // Frustration detection is internal-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, isShuttingDown } 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 { applyToolResultReplacementsToMessages, 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 = require('../hooks/useScheduledTasks.js').useScheduledTasks; /* 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'; import { getAPIProvider } from '../utils/model/providers.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 { isPromptTypingSuppressionActive } from './replInputSuppression.js'; import { shouldRunStartupChecks } from './replStartupGates.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 { isBuddyEnabled } from '../buddy/feature.js'; import { fireCompanionObserver } from '../buddy/observer.js'; import { DevBar } from '../components/DevBar.js'; // Session manager removed - using AppState now import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; import { REMOTE_SAFE_COMMANDS } from '../commands.js'; import type { RemoteMessageContent } from '../utils/teleport/api.js'; import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js'; import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js'; import { AlternateScreen } from '../ink/components/AlternateScreen.js'; import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'; import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type MessageActionsState, type MessageActionsNav, type MessageActionCaps } from '../components/messageActions.js'; import { setClipboard } from '../ink/termio/osc.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would // cause useEffect dependency changes and infinite re-render loops. const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => { } }; // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll // up to read the start → start typing → before this fix, snapped to bottom. // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739 const RECENT_SCROLL_REPIN_WINDOW_MS = 3000; // Use LRU cache to prevent unbounded memory growth // 100 files should be sufficient for most coding sessions while preventing // memory issues when working across many files in large projects function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]!; } /** * Small component to display transcript mode footer with dynamic keybinding. * Must be rendered inside KeybindingSetup to access keybinding context. */ function TranscriptModeFooter(t0) { const $ = _c(9); const { showAllInTranscript, virtualScroll, searchBadge, suppressShowAll: t1, status } = t0; const suppressShowAll = t1 === undefined ? false : t1; const toggleShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); const showAllShortcut = useShortcutDisplay("transcript:toggleShowAll", "Transcript", "ctrl+e"); const t2 = searchBadge ? " \xB7 n/N to navigate" : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? "" : ` · ${showAllShortcut} to ${showAllInTranscript ? "collapse" : "show all"}`; let t3; if ($[0] !== t2 || $[1] !== toggleShortcut) { t3 = Showing detailed transcript · {toggleShortcut} to toggle{t2}; $[0] = t2; $[1] = toggleShortcut; $[2] = t3; } else { t3 = $[2]; } let t4; if ($[3] !== searchBadge || $[4] !== status) { t4 = status ? <>{status} : searchBadge ? <>{searchBadge.current}/{searchBadge.count}{" "} : null; $[3] = searchBadge; $[4] = status; $[5] = t4; } else { t4 = $[5]; } let t5; if ($[6] !== t3 || $[7] !== t4) { t5 = {t3}{t4}; $[6] = t3; $[7] = t4; $[8] = t5; } else { t5 = $[8]; } return t5; } /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter * so swapping them in the bottom slot doesn't shift ScrollBox height. * useSearchInput handles readline editing; we report query changes and * render the counter. Incremental — re-search + highlight per keystroke. */ function TranscriptSearchBar({ jumpRef, count, current, onClose, onCancel, setHighlight, initialQuery }: { jumpRef: RefObject; 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(t0) { const $ = _c(6); const { isAnimating, title, disabled, noPrefix } = t0; const terminalFocused = useTerminalFocus(); const [frame, setFrame] = useState(0); let t1; let t2; if ($[0] !== disabled || $[1] !== isAnimating || $[2] !== noPrefix || $[3] !== terminalFocused) { t1 = () => { if (disabled || noPrefix || !isAnimating || !terminalFocused) { return; } const interval = setInterval(_temp2, TITLE_ANIMATION_INTERVAL_MS, setFrame); return () => clearInterval(interval); }; t2 = [disabled, noPrefix, isAnimating, terminalFocused]; $[0] = disabled; $[1] = isAnimating; $[2] = noPrefix; $[3] = terminalFocused; $[4] = t1; $[5] = t2; } else { t1 = $[4]; t2 = $[5]; } useEffect(t1, t2); const prefix = isAnimating ? TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX : TITLE_STATIC_PREFIX; useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`); return null; } function _temp2(setFrame_0) { return setFrame_0(_temp); } function _temp(f) { return (f + 1) % TITLE_ANIMATION_FRAMES.length; } export type Props = { commands: Command[]; debug: boolean; initialTools: Tool[]; // Initial messages to populate the REPL with initialMessages?: MessageType[]; // Deferred hook messages promise — REPL renders immediately and injects // hook messages when they resolve. Awaited before the first API call. pendingHookMessages?: Promise; 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; // 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 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 | 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 (internal-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 const pluginCommands = 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. // Deferring startup checks is handled below (after promptTypingSuppressionActive // is declared) to avoid temporal dead zone issues. // 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, pluginCommands as Command[]); const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]); // Keep plugin commands out of render-time command props. Feeding the full // execution set into PromptInput/Messages reintroduced the startup repaint // freeze, while transcript rendering still round-trips plugin skills via the // SkillTool's `skill` payload without needing plugin command objects here. const renderMergedCommands = useMergedCommands(localCommands, mcp.commands as Command[]); // Filter out all commands if disableSlashCommands is true const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]); const renderCommands = useMemo(() => disableSlashCommands ? [] : renderMergedCommands, [disableSlashCommands, renderMergedCommands]); 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([]); 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); // 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(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([]); // 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 void; }>>([]); const [promptQueue, setPromptQueue] = useState void; reject: (error: Error) => void; }>>([]); // Track bridge cleanup functions for sandbox permission requests so the // local dialog handler can cancel the remote prompt when the local user // responds first. Keyed by host to support concurrent same-host requests. const sandboxBridgeCleanupRef = useRef void>>>(new Map()); // -- Terminal title management // Session title (set via /rename or restored on resume) wins over // the agent name, which wins over the Haiku-extracted topic; // all fall back to the product name. const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false; const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined; const [haikuTitle, setHaikuTitle] = useState(); // Gates the one-shot Haiku call that generates the tab title. Seeded true // on resume (initialMessages present) so we don't re-title a resumed // session from mid-conversation context. const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0); const agentTitle = mainThreadAgentDefinition?.agentType; const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Open Claude'; 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 internal-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(() => { if (!initialMessages) return []; const initialReplacementState = provisionContentReplacementState(initialMessages, initialContentReplacements); return initialReplacementState ? applyToolResultReplacementsToMessages(initialMessages, initialReplacementState.replacements) : 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); }, []); const syncToolResultReplacements = useCallback((replacements: ReadonlyMap) => { if (replacements.size === 0) return; setMessages(current => applyToolResultReplacementsToMessages(current, replacements)); }, [setMessages]); // 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 (isBuddyEnabled()) { 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 promptTypingSuppressionActive = isPromptTypingSuppressionActive(isPromptInputActive, 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>({}); const [submitCount, setSubmitCount] = useState(0); // Defer startup checks until the user has submitted their first message. // A timeout or grace period is insufficient (issue #363): if the user pauses // before typing, startup checks can still fire and recommendation dialogs // steal focus. Only the user's first submission guarantees the prompt was // the first thing they interacted with. const startupChecksStartedRef = React.useRef(false); const hasHadFirstSubmission = (submitCount ?? 0) > 0; useEffect(() => { if (isRemoteSession) return; if (startupChecksStartedRef.current) return; if (!shouldRunStartupChecks({ isRemoteSession, hasStarted: startupChecksStartedRef.current, hasHadFirstSubmission, })) return; startupChecksStartedRef.current = true; void performStartupChecks(setAppState); }, [setAppState, isRemoteSession, hasHadFirstSubmission]); // 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 internal-only spinner display (TTFT/OTPS). // Accumulates metrics from all API requests in a turn for P50 aggregation. const apiMetricsRef = useRef>([]); 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(null); const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false); const [messageSelectorPreselect, setMessageSelectorPreselect] = useState(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 // Haiku title shouldn't carry over. haikuTitleAttemptedRef.current = true; setHaikuTitle(undefined); // Exit any worktree a prior /resume entered, then cd into the one // this session was in. Without the exit, resuming from worktree B // to non-worktree C leaves cwd/currentWorktreeSession stale; // resuming B→C where C is also a worktree fails entirely // (getCurrentWorktreeSession guard blocks the switch). // // Skipped for /branch: forkLog doesn't carry worktreeSession, so // this would kick the user out of a worktree they're still working // in. Same fork skip as processResumedConversation for the adopt — // fork materializes its own file via recordTranscript on REPL mount. if (entrypoint !== 'fork') { exitRestoredWorktree(); restoreWorktreeForResume(log.worktreeSession); adoptResumedSessionFile(); void restoreRemoteAgentTasks({ abortController: new AbortController(), getAppState: () => store.getState(), setAppState }); } else { // Fork: same re-persist as /clear (conversation.ts). The clear // above wiped currentSessionWorktree, forkLog doesn't carry it, // and the process is still in the same worktree. const ws = getCurrentWorktreeSession(); if (ws) saveWorktreeState(ws); } // Persist the current mode so future resumes know what mode this session was in if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { saveMode } = require('../utils/sessionStorage.js'); const { isCoordinatorMode } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); /* eslint-enable @typescript-eslint/no-require-imports */ saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); } // Restore target session's costs from the data we read earlier if (targetSessionCosts) { setCostStateForRestore(targetSessionCosts); } // Reconstruct replacement state for the resumed session. Runs after // setSessionId so any NEW replacements post-resume write to the // resumed session's tool-results dir. Gated on ref.current: the // initial mount already read the feature flag, so we don't re-read // it here (mid-session flag flips stay unobservable in both // directions). // // Skipped for in-session /branch: the existing ref is already correct // (branch preserves tool_use_ids), so there's no need to reconstruct. // createFork() does write content-replacement entries to the forked // JSONL with the fork's sessionId, so `claude -r {forkId}` also works. if (contentReplacementStateRef.current && entrypoint !== 'fork') { contentReplacementStateRef.current = reconstructContentReplacementState(messages, log.contentReplacements ?? []); } const hydratedMessages = contentReplacementStateRef.current ? applyToolResultReplacementsToMessages(messages, contentReplacementStateRef.current.replacements) : messages; // Reset messages to the provided initial messages // Use a callback to ensure we're not dependent on stale state setMessages(() => hydratedMessages); // 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' | '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 (promptTypingSuppressionActive) return undefined; if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission'; // Permission/interactive dialogs (show unless blocked by toolJSX) const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation; if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission'; if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'; // Worker sandbox permission prompts (network access) from swarm workers if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission'; if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation'; if (allowDialogsWithAnimation && showingCostDialog) return 'cost'; if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return'; if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) return 'ultraplan-choice'; if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) return 'ultraplan-launch'; // Onboarding dialogs (special conditions) if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; // Model switch callout (internal-only, eliminated from external builds) if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; // Undercover auto-enable explainer (internal-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) // Suppress during startup window to prevent stealing focus from the prompt (issue #363) if (allowDialogsWithAnimation && lspRecommendation && startupChecksStartedRef.current) return 'lsp-recommendation'; // Plugin hint from CLI/SDK stderr (same priority band as LSP rec) if (allowDialogsWithAnimation && hintRecommendation && startupChecksStartedRef.current) return 'plugin-hint'; // Desktop app upsell (max 3 launches, lowest priority) if (allowDialogsWithAnimation && showDesktopUpsellStartup && startupChecksStartedRef.current) return 'desktop-upsell'; return undefined; } const focusedInputDialog = getFocusedInputDialog(); // True when permission prompts exist but are hidden because the user is typing const hasSuppressedDialogs = promptTypingSuppressionActive && (sandboxPermissionRequestQueue[0] || toolUseConfirmQueue[0] || promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || showingCostDialog); // Keep ref in sync so timer callbacks can read the current value focusedInputDialogRef.current = focusedInputDialog; // Immediately capture pause/resume when focusedInputDialog changes // This ensures accurate timing even under high system load, rather than // relying on the 100ms polling interval to detect state changes useEffect(() => { if (!isLoading) return; const isPaused = focusedInputDialog === 'tool-permission'; const now = Date.now(); if (isPaused && pauseStartTimeRef.current === null) { // Just entered pause state - record the exact moment pauseStartTimeRef.current = now; } else if (!isPaused && pauseStartTimeRef.current !== null) { // Just exited pause state - accumulate paused time immediately totalPausedMsRef.current += now - pauseStartTimeRef.current; pauseStartTimeRef.current = null; } }, [focusedInputDialog, isLoading]); // Re-pin scroll to bottom whenever the permission overlay appears or // dismisses. Overlay now renders below messages inside the same // ScrollBox (no remount), so we need an explicit scrollToBottom for: // - appear: user may have been scrolled up (sticky broken) — the // dialog is blocking and must be visible // - dismiss: user may have scrolled up to read context during the // overlay, and onScroll was suppressed so the pill state is stale // useLayoutEffect so the re-pin commits before the Ink frame renders — // no 1-frame flash of the wrong scroll position. const prevDialogRef = useRef(focusedInputDialog); useLayoutEffect(() => { const was = prevDialogRef.current === 'tool-permission'; const now = focusedInputDialog === 'tool-permission'; if (was !== now) repinScroll(); prevDialogRef.current = focusedInputDialog; }, [focusedInputDialog, repinScroll]); function onCancel() { if (focusedInputDialog === 'elicitation') { // Elicitation dialog handles its own Escape, and closing it shouldn't affect any loading state. return; } logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`); // Pause proactive mode so the user gets control back. // It will resume when they submit their next input (see onSubmit). if (feature('PROACTIVE') || feature('KAIROS')) { proactiveModule?.pauseProactive(); } queryGuard.forceEnd(); skipIdleCheckRef.current = false; // Preserve partially-streamed text so the user can read what was // generated before pressing Esc. Pushed before resetLoadingState clears // streamingText, and before query.ts yields the async interrupt marker, // giving final order [user, partial-assistant, [Request interrupted by user]]. if (streamingText?.trim()) { setMessages(prev => [...prev, createAssistantMessage({ content: streamingText })]); } resetLoadingState(); // Clear any active token budget so the backstop doesn't fire on // a stale budget if the query generator hasn't exited yet. if (feature('TOKEN_BUDGET')) { snapshotOutputTokensForTurn(null); } if (focusedInputDialog === 'tool-permission') { // Tool use confirm handles the abort signal itself toolUseConfirmQueue[0]?.onAbort(); setToolUseConfirmQueue([]); } else if (focusedInputDialog === 'prompt') { // Reject all pending prompts and clear the queue for (const item of promptQueue) { item.reject(new Error('Prompt cancelled by user')); } setPromptQueue([]); abortController?.abort('user-cancel'); } else if (activeRemote.isRemoteMode) { // Remote mode: send interrupt signal to CCR activeRemote.cancelRequest(); } else { abortController?.abort('user-cancel'); } // Clear the controller so subsequent Escape presses don't see a stale // aborted signal. Without this, canCancelRunningTask is false (signal // defined but .aborted === true), so isActive becomes false if no other // activating conditions hold — leaving the Escape keybinding inactive. setAbortController(null); // forceEnd() skips the finally path — fire directly (aborted=true). void mrOnTurnComplete(messagesRef.current, true); } // Function to handle queued command when canceling a permission request const handleQueuedCommandOnCancel = useCallback(() => { const result = popAllEditable(inputValue, 0); if (!result) return; setInputValue(result.text); setInputMode('prompt'); // Restore images from queued commands to pastedContents if (result.images.length > 0) { setPastedContents(prev => { const newContents = { ...prev }; for (const image of result.images) { newContents[image.id] = image; } return newContents; }); } }, [setInputValue, setInputMode, inputValue, setPastedContents]); // CancelRequestHandler props - rendered inside KeybindingSetup const cancelRequestProps = { setToolUseConfirmQueue, onCancel, onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]), isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog, screen, abortSignal: abortController?.signal, popCommandFromQueue: handleQueuedCommandOnCancel, vimMode, isLocalJSXCommand: toolJSX?.isLocalJSXCommand, isSearchingHistory, isHelpOpen, inputMode, inputValue, streamMode }; useEffect(() => { const totalCost = getTotalCost(); if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) { logEvent('tengu_cost_threshold_reached', {}); // Mark as shown even if the dialog won't render (no console billing // access). Otherwise this effect re-fires on every message change for // the rest of the session — 200k+ spurious events observed. setHaveShownCostDialog(true); if (hasConsoleBillingAccess()) { setShowCostDialog(true); } } }, [messages, showCostDialog, haveShownCostDialog]); const sandboxAskCallback: SandboxAskCallback = useCallback(async (hostPattern: NetworkHostPattern) => { // If running as a swarm worker, forward the request to the leader via mailbox if (isAgentSwarmsEnabled() && isSwarmWorker()) { const requestId = generateSandboxRequestId(); // Send the request to the leader via mailbox const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId); return new Promise(resolveShouldAllowHost => { if (!sent) { // If we couldn't send via mailbox, fall back to local handling setSandboxPermissionRequestQueue(prev => [...prev, { hostPattern, resolvePromise: resolveShouldAllowHost }]); return; } // Register the callback for when the leader responds registerSandboxPermissionCallback({ requestId, host: hostPattern.host, resolve: resolveShouldAllowHost }); // Update AppState to show pending indicator setAppState(prev => ({ ...prev, pendingSandboxRequest: { requestId, host: hostPattern.host } })); }); } // Normal flow for non-workers: show local UI and optionally race // against the REPL bridge (Remote Control) if connected. return new Promise(resolveShouldAllowHost => { let resolved = false; function resolveOnce(allow: boolean): void { if (resolved) return; resolved = true; resolveShouldAllowHost(allow); } // Queue the local sandbox permission dialog setSandboxPermissionRequestQueue(prev => [...prev, { hostPattern, resolvePromise: resolveOnce }]); // When the REPL bridge is connected, also forward the sandbox // permission request as a can_use_tool control_request so the // remote user (e.g. on claude.ai) can approve it too. if (feature('BRIDGE_MODE')) { const bridgeCallbacks = store.getState().replBridgePermissionCallbacks; if (bridgeCallbacks) { const bridgeRequestId = randomUUID(); bridgeCallbacks.sendRequest(bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, { host: hostPattern.host }, randomUUID(), `Allow network connection to ${hostPattern.host}?`); const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => { unsubscribe(); const allow = response.behavior === 'allow'; // Resolve ALL pending requests for the same host, not just // this one — mirrors the local dialog handler pattern. setSandboxPermissionRequestQueue(queue => { queue.filter(item => item.hostPattern.host === hostPattern.host).forEach(item => item.resolvePromise(allow)); return queue.filter(item => item.hostPattern.host !== hostPattern.host); }); // Clean up all sibling bridge subscriptions for this host // (other concurrent same-host requests) before deleting. const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host); if (siblingCleanups) { for (const fn of siblingCleanups) { fn(); } sandboxBridgeCleanupRef.current.delete(hostPattern.host); } }); // Register cleanup so the local dialog handler can cancel // the remote prompt and unsubscribe when the local user // responds first. const cleanup = () => { unsubscribe(); bridgeCallbacks.cancelRequest(bridgeRequestId); }; const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? []; existing.push(cleanup); sandboxBridgeCleanupRef.current.set(hostPattern.host, existing); } } }); }, [setAppState, store]); // #34044: if user explicitly set sandbox.enabled=true but deps are missing, // isSandboxingEnabled() returns false silently. Surface the reason once at // mount so users know their security config isn't being enforced. Full // reason goes to debug log; notification points to /sandbox for details. // addNotification is stable (useCallback) so the effect fires once. useEffect(() => { const reason = SandboxManager.getSandboxUnavailableReason(); if (!reason) return; if (SandboxManager.isSandboxRequired()) { process.stderr.write(`\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`); gracefulShutdownSync(1, 'other'); return; } logForDebugging(`sandbox disabled: ${reason}`, { level: 'warn' }); addNotification({ key: 'sandbox-unavailable', jsx: <> 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, syncToolResultReplacements }; }, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId, syncToolResultReplacements]); // 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) => { // Prepare IDE integration for new prompt. Read mcpClients fresh from // store — useManageMCPConnections may have populated it since the // render that captured this closure (same pattern as computeTools). if (shouldQuery) { const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients); void diagnosticTracker.handleQueryStart(freshClients); const ideClient = getConnectedIdeClient(freshClients); if (ideClient) { void closeOpenDiffs(ideClient); } } // Mark onboarding as complete when any user message is sent to Claude void maybeMarkProjectOnboardingComplete(); // Extract a session title from the first real user message. One-shot // via ref (was tengu_birch_mist experiment: first-message-only to save // Haiku calls). The ref replaces the old `messages.length <= 1` check, // which was broken by SessionStart hook messages (prepended via // useDeferredHookMessages) and attachment messages (appended by // processTextPrompt) — both pushed length past 1 on turn one, so the // title silently fell through to the "Claude Code" default. if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content) : null; // Skip synthetic breadcrumbs — slash-command output, prompt-skill // expansions (/commit → ), 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}>`)) { haikuTitleAttemptedRef.current = true; void generateSessionTitle(text, new AbortController().signal).then(title => { if (title) setHaikuTitle(title); else haikuTitleAttemptedRef.current = false; }, () => { haikuTitleAttemptedRef.current = false; }); } } // Apply slash-command-scoped allowedTools (from skill frontmatter) to the // store once per turn. This also covers the reset: the next non-skill turn // passes [] and clears it. Must run before the !shouldQuery gate: forked // commands (executeForkedSlashCommand) return shouldQuery=false, and // createGetAppStateWithAllowedTools in forkedAgent.ts reads this field, so // stale skill tools would otherwise leak into forked agent permissions. // Previously this write was hidden inside getToolUseContext's getAppState // (~85 calls/turn); hoisting it here makes getAppState a pure read and stops // ephemeral contexts (permission dialog, BackgroundTasksDialog) from // accidentally clearing it mid-turn. store.setState(prev => { const cur = prev.toolPermissionContext.alwaysAllowRules.command; if (cur === additionalAllowedTools || cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) { return prev; } return { ...prev, toolPermissionContext: { ...prev.toolPermissionContext, alwaysAllowRules: { ...prev.toolPermissionContext.alwaysAllowRules, command: additionalAllowedTools } } }; }); // The last message is an assistant message if the user input was a bash command, // or if the user input was an invalid slash command. if (!shouldQuery) { // Manual /compact sets messages directly (shouldQuery=false) bypassing // handleMessageFromStream. Clear context-blocked if a compact boundary // is present so proactive ticks resume after compaction. if (newMessages.some(isCompactBoundaryMessage)) { // Bump conversationId so Messages.tsx row keys change and // stale memoized rows remount with post-compact content. setConversationId(randomUUID()); if (feature('PROACTIVE') || feature('KAIROS')) { proactiveModule?.setContextBlocked(false); } } resetLoadingState(); setAbortController(null); return; } const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam); // getToolUseContext reads tools/mcpClients fresh from store.getState() // (via computeTools/mergeClients). Use those rather than the closure- // captured `tools`/`mcpClients` — useManageMCPConnections may have // flushed new MCP state between the render that captured this closure // and now. Turn 1 via processInitialMessage is the main beneficiary. const { tools: freshTools, mcpClients: freshMcpClients } = toolUseContext.options; // Scope the skill's effort override to this turn's context only — // wrapping getAppState keeps the override out of the global store so // background agents and UI subscribers (Spinner, LogoV2) never see it. if (effort !== undefined) { const previousGetAppState = toolUseContext.getAppState; toolUseContext.getAppState = () => ({ ...previousGetAppState(), effortValue: effort }); } queryCheckpoint('query_context_loading_start'); const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ // IMPORTANT: do this after setMessages() above, to avoid UI jank checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]); const userContext = { ...baseUserContext, ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined), ...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? { terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.' } : {}) }; queryCheckpoint('query_context_loading_end'); const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt, defaultSystemPrompt, appendSystemPrompt }); toolUseContext.renderedSystemPrompt = systemPrompt; queryCheckpoint('query_query_start'); resetTurnHookDuration(); resetTurnToolDuration(); resetTurnClassifierDuration(); for await (const event of query({ messages: messagesIncludingNewMessages, systemPrompt, userContext, systemContext, canUseTool, toolUseContext, querySource: getQuerySourceForREPL() })) { onQueryEvent(event); } if (isBuddyEnabled()) { void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { ...prev, companionReaction: reaction })); } queryCheckpoint('query_end'); // Capture internal-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): 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); } 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 (internal-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 (internal-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 }); haikuTitleAttemptedRef.current = false; setHaikuTitle(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 }); // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) // and session-only /loop runs. const assistantMode = store.getState().kairosEnabled; 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; // 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={ {isBuddyEnabled() && 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 }); haikuTitleAttemptedRef.current = false; setHaikuTitle(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 && !isShuttingDown() && <> {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 (internal-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' && } {isBuddyEnabled() && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? : null} } /> ; if (isFullscreenEnvEnabled()) { return {mainReturn} ; } return mainReturn; }