import { feature } from 'bun:bundle' import type { UUID } from 'crypto' import figures from 'figures' import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { useNotifications } from 'src/context/notifications.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { useAppState, useAppStateStore, useSetAppState, } from 'src/state/AppState.js' import { getSdkBetas, getSessionId, isSessionPersistenceDisabled, setHasExitedPlanMode, setNeedsAutoModeExitAttachment, setNeedsPlanModeExitAttachment, } from '../../../bootstrap/state.js' import { generateSessionName } from '../../../commands/rename/generateSessionName.js' import { launchUltraplan } from '../../../commands/ultraplan.js' import { type KeyboardEvent, Box, Text } from '@anthropic/ink' import type { AppState } from '../../../state/AppStateStore.js' import { AGENT_TOOL_NAME } from '../../../tools/AgentTool/constants.js' import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js' import type { AllowedPrompt } from '../../../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' import { TEAM_CREATE_TOOL_NAME } from '../../../tools/TeamCreateTool/constants.js' import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js' import { calculateContextPercentages, getContextWindowForModel, } from '../../../utils/context.js' import { getExternalEditor } from '../../../utils/editor.js' import { getDisplayPath } from '../../../utils/file.js' import { toIDEDisplayName } from '../../../utils/ide.js' import { logError } from '../../../utils/log.js' import { enqueuePendingNotification } from '../../../utils/messageQueueManager.js' import { createUserMessage } from '../../../utils/messages.js' import { getMainLoopModel, getRuntimeMainLoopModel, } from '../../../utils/model/model.js' import { createPromptRuleContent, isClassifierPermissionsEnabled, PROMPT_PREFIX, } from '../../../utils/permissions/bashClassifier.js' import { type PermissionMode, toExternalPermissionMode, } from '../../../utils/permissions/PermissionMode.js' import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' import { isAutoModeGateEnabled, restoreDangerousPermissions, stripDangerousPermissionsForAutoMode, } from '../../../utils/permissions/permissionSetup.js' import { getPewterLedgerVariant, isPlanModeInterviewPhaseEnabled, } from '../../../utils/planModeV2.js' import { getPlan, getPlanFilePath } from '../../../utils/plans.js' import { editFileInEditor, editPromptInEditor, } from '../../../utils/promptEditor.js' import { getCurrentSessionTitle, getTranscriptPath, saveAgentName, saveCustomTitle, } from '../../../utils/sessionStorage.js' import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js' import { type OptionWithDescription, Select } from '../../CustomSelect/index.js' import { Markdown } from '../../Markdown.js' import { PermissionDialog } from '../PermissionDialog.js' import type { PermissionRequestProps } from '../PermissionRequest.js' import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' /* eslint-disable @typescript-eslint/no-require-imports */ const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('../../../utils/permissions/autoModeState.js') as typeof import('../../../utils/permissions/autoModeState.js')) : null import type { Base64ImageSource, ImageBlockParam, } from '@anthropic-ai/sdk/resources/messages.mjs' /* eslint-enable @typescript-eslint/no-require-imports */ import type { PastedContent } from '../../../utils/config.js' import type { ImageDimensions } from '../../../utils/imageResizer.js' import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js' import { cacheImagePath, storeImage } from '../../../utils/imageStore.js' type ResponseValue = | 'yes-bypass-permissions' | 'yes-accept-edits' | 'yes-accept-edits-keep-context' | 'yes-default-keep-context' | 'yes-resume-auto-mode' | 'yes-auto-clear-context' | 'ultraplan' | 'no' /** * Build permission updates for plan approval, including prompt-based rules if provided. * Prompt-based rules are only added when classifier permissions are enabled (Ant-only). */ export function buildPermissionUpdates( mode: PermissionMode, allowedPrompts?: AllowedPrompt[], ): PermissionUpdate[] { const updates: PermissionUpdate[] = [ { type: 'setMode', mode: toExternalPermissionMode(mode), destination: 'session', }, ] // Add prompt-based permission rules if provided (Ant-only feature) if ( isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 ) { updates.push({ type: 'addRules', rules: allowedPrompts.map(p => ({ toolName: p.tool, ruleContent: createPromptRuleContent(p.prompt), })), behavior: 'allow', destination: 'session', }) } return updates } /** * Auto-name the session from the plan content when the user accepts a plan, * if they haven't already named it via /rename or --name. Fire-and-forget. * Mirrors /rename: kebab-case name, updates the prompt-border badge. */ export function autoNameSessionFromPlan( plan: string, setAppState: (updater: (prev: AppState) => AppState) => void, isClearContext: boolean, ): void { if ( isSessionPersistenceDisabled() || getSettings_DEPRECATED()?.cleanupPeriodDays === 0 ) { return } // On clear-context, the current session is about to be abandoned — its // title (which may have been set by a PRIOR auto-name) is irrelevant. // Checking it would make the feature self-defeating after first use. if (!isClearContext && getCurrentSessionTitle(getSessionId())) return void generateSessionName( // generateSessionName tail-slices to the last 1000 chars (correct for // conversations, where recency matters). Plans front-load the goal and // end with testing steps — head-slice so Haiku sees the summary. [createUserMessage({ content: plan.slice(0, 1000) })], new AbortController().signal, ) .then(async name => { // On clear-context acceptance, regenerateSessionId() has run by now — // this intentionally names the NEW execution session. Do not "fix" by // capturing sessionId once; that would name the abandoned planning session. if (!name || getCurrentSessionTitle(getSessionId())) return const sessionId = getSessionId() as UUID const fullPath = getTranscriptPath() await saveCustomTitle(sessionId, name, fullPath, 'auto') await saveAgentName(sessionId, name, fullPath, 'auto') setAppState(prev => { if (prev.standaloneAgentContext?.name === name) return prev return { ...prev, standaloneAgentContext: { ...prev.standaloneAgentContext, name }, } }) }) .catch(logError) } export function ExitPlanModePermissionRequest({ toolUseConfirm, onDone, onReject, workerBadge, setStickyFooter, }: PermissionRequestProps): React.ReactNode { const toolPermissionContext = useAppState(s => s.toolPermissionContext) const setAppState = useSetAppState() const store = useAppStateStore() const { addNotification } = useNotifications() // Feedback text from the 'No' option's input. Threaded through onAllow as // acceptFeedback when the user approves — lets users annotate the plan // ("also update the README") without a reject+re-plan round-trip. const [planFeedback, setPlanFeedback] = useState('') const [pastedContents, setPastedContents] = useState< Record >({}) const nextPasteIdRef = useRef(0) const showClearContext = useAppState(s => s.settings.showClearContextOnPlanAccept) ?? false const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) // Hide the Ultraplan button while a session is active or launching — // selecting it would dismiss the dialog and reject locally before // launchUltraplan can notice the session exists and return "already polling". // feature() must sit directly in an if/ternary (bun:bundle DCE constraint). const showUltraplan = feature('ULTRAPLAN') ? !ultraplanSessionUrl && !ultraplanLaunching : false const usage = toolUseConfirm.assistantMessage.message.usage const { mode, isAutoModeAvailable, isBypassPermissionsModeAvailable } = toolPermissionContext const options = useMemo( () => buildPlanApprovalOptions({ showClearContext, showUltraplan, usedPercent: showClearContext ? getContextUsedPercent(usage, mode) : null, isAutoModeAvailable, isBypassPermissionsModeAvailable, onFeedbackChange: setPlanFeedback, }), [ showClearContext, showUltraplan, usage, mode, isAutoModeAvailable, isBypassPermissionsModeAvailable, ], ) function onImagePaste( base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, _sourcePath?: string, ) { const pasteId = nextPasteIdRef.current++ const newContent: PastedContent = { id: pasteId, type: 'image', content: base64Image, mediaType: mediaType || 'image/png', filename: filename || 'Pasted image', dimensions, } cacheImagePath(newContent) void storeImage(newContent) setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) } const onRemoveImage = useCallback((id: number) => { setPastedContents(prev => { const next = { ...prev } delete next[id] return next }) }, []) const imageAttachments = Object.values(pastedContents).filter( c => c.type === 'image', ) const hasImages = imageAttachments.length > 0 // TODO: Delete the branch after moving to V2 // Use tool name to detect V2 instead of checking input.plan, because PR #10394 // injects plan content into input.plan for hooks/SDK, which broke the old detection // (see issue #10878) const isV2 = toolUseConfirm.tool.name === EXIT_PLAN_MODE_V2_TOOL_NAME const inputPlan = isV2 ? undefined : (toolUseConfirm.input.plan as string | undefined) const planFilePath = isV2 ? getPlanFilePath() : undefined // Extract allowed prompts requested by the plan (Ant-only feature) const allowedPrompts = toolUseConfirm.input.allowedPrompts as | AllowedPrompt[] | undefined // Get the raw plan to check if it's empty const rawPlan = inputPlan ?? getPlan() const isEmpty = !rawPlan || rawPlan.trim() === '' // Capture the variant once on mount. GrowthBook reads from a disk cache // so the value is stable across a single planning session. undefined = // control arm. The variant is a fixed 3-value enum of short literals, // not user input. const [planStructureVariant] = useState( () => (getPewterLedgerVariant() ?? undefined) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ) const [currentPlan, setCurrentPlan] = useState(() => { if (inputPlan) return inputPlan const plan = getPlan() return ( plan ?? 'No plan found. Please write your plan to the plan file first.' ) }) const [showSaveMessage, setShowSaveMessage] = useState(false) // Track Ctrl+G local edits so updatedInput can include the plan (the tool // only echoes the plan in tool_result when input.plan is set — otherwise // the model already has it in context from writing the plan file). const [planEditedLocally, setPlanEditedLocally] = useState(false) // Auto-hide save message after 5 seconds useEffect(() => { if (showSaveMessage) { const timer = setTimeout(setShowSaveMessage, 5000, false) return () => clearTimeout(timer) } }, [showSaveMessage]) // Handle Ctrl+G to edit plan in $EDITOR, Shift+Tab for auto-accept edits const handleKeyDown = (e: KeyboardEvent): void => { if (e.ctrl && e.key === 'g') { e.preventDefault() logEvent('tengu_plan_external_editor_used', {}) void (async () => { if (isV2 && planFilePath) { const result = await editFileInEditor(planFilePath) if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', priority: 'high', }) } if (result.content !== null) { if (result.content !== currentPlan) setPlanEditedLocally(true) setCurrentPlan(result.content) setShowSaveMessage(true) } } else { const result = await editPromptInEditor(currentPlan) if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', priority: 'high', }) } if (result.content !== null && result.content !== currentPlan) { setCurrentPlan(result.content) setShowSaveMessage(true) } } })() return } // Shift+Tab immediately selects "auto-accept edits" if (e.shift && e.key === 'tab') { e.preventDefault() void handleResponse( showClearContext ? 'yes-accept-edits' : 'yes-accept-edits-keep-context', ) return } } async function handleResponse(value: ResponseValue): Promise { const trimmedFeedback = planFeedback.trim() const acceptFeedback = trimmedFeedback || undefined // Ultraplan: reject locally, teleport the plan to CCR as a seed draft. // Dialog dismisses immediately so the query loop unblocks; the teleport // runs detached and its launch message lands via the command queue. if (value === 'ultraplan') { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: 'ultraplan' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, }) onDone() onReject() toolUseConfirm.onReject( 'Plan being refined via Ultraplan — please wait for the result.', ) void launchUltraplan({ blurb: '', seedPlan: currentPlan, getAppState: store.getState, setAppState: store.setState, signal: new AbortController().signal, }) .then(msg => enqueuePendingNotification({ value: msg, mode: 'task-notification' }), ) .catch(logError) return } // V1: pass plan in input. V2: plan is on disk, but if the user edited it // via Ctrl+G we pass it through so the tool echoes the edit in tool_result // (otherwise the model never sees the user's changes). const updatedInput = isV2 && !planEditedLocally ? {} : { plan: currentPlan } // If auto was active during plan (from auto mode or opt-in) and NOT going // to auto, deactivate auto + restore permissions + fire exit attachment. if (feature('TRANSCRIPT_CLASSIFIER')) { const goingToAuto = (value === 'yes-resume-auto-mode' || value === 'yes-auto-clear-context') && isAutoModeGateEnabled() // isAutoModeActive() is the authoritative signal — prePlanMode/ // strippedDangerousRules are stale after transitionPlanAutoMode // deactivates mid-plan (would cause duplicate exit attachment). const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false if (value !== 'no' && !goingToAuto && autoWasUsedDuringPlan) { autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) setAppState(prev => ({ ...prev, toolPermissionContext: { ...restoreDangerousPermissions(prev.toolPermissionContext), prePlanMode: undefined, }, })) } } // Clear-context options: set pending plan implementation and reject the dialog // The REPL will handle context clear and trigger a fresh query // Keep-context options skip this block and go through the normal flow below const isResumeAutoOption = feature('TRANSCRIPT_CLASSIFIER') ? value === 'yes-resume-auto-mode' : false const isKeepContextOption = value === 'yes-accept-edits-keep-context' || value === 'yes-default-keep-context' || isResumeAutoOption if (value !== 'no') { autoNameSessionFromPlan(currentPlan, setAppState, !isKeepContextOption) } if (value !== 'no' && !isKeepContextOption) { // Determine the permission mode based on the selected option let mode: PermissionMode = 'default' if (value === 'yes-bypass-permissions') { mode = 'bypassPermissions' } else if (value === 'yes-accept-edits') { mode = 'acceptEdits' } else if ( feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-auto-clear-context' && isAutoModeGateEnabled() ) { // REPL's processInitialMessage handles stripDangerousPermissions + mode, // but does NOT set autoModeActive. Gate-off falls through to 'default'. mode = 'auto' autoModeStateModule?.setAutoModeActive(true) } // Log plan exit event logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: true, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, }) // Set initial message - REPL will handle context clear and fresh query // Add verification instruction if the feature is enabled // Dead code elimination: CLAUDE_CODE_VERIFY_PLAN='false' in external builds, so === 'true' check allows Bun to eliminate the string const verificationInstruction = undefined === 'true' ? `\n\nIMPORTANT: When you have finished implementing the plan, you MUST call the "VerifyPlanExecution" tool directly (NOT the ${AGENT_TOOL_NAME} tool or an agent) to trigger background verification.` : '' // Capture the transcript path before context is cleared (session ID will be regenerated) const transcriptPath = getTranscriptPath() const transcriptHint = `\n\nIf you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}` const teamHint = isAgentSwarmsEnabled() ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` : '' const feedbackSuffix = acceptFeedback ? `\n\nUser feedback on this plan: ${acceptFeedback}` : '' setAppState(prev => ({ ...prev, initialMessage: { message: { ...createUserMessage({ content: `Implement the following plan:\n\n${currentPlan}${verificationInstruction}${transcriptHint}${teamHint}${feedbackSuffix}`, }), planContent: currentPlan, }, clearContext: true, mode, allowedPrompts, }, })) setHasExitedPlanMode(true) onDone() onReject() // Reject the tool use to unblock the query loop // The REPL will see pendingInitialQuery and trigger fresh query toolUseConfirm.onReject() return } // Handle auto keep-context option — needs special handling because // buildPermissionUpdates maps auto to 'default' via toExternalPermissionMode. // We set the mode directly via setAppState and sync the bootstrap state. if ( feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-resume-auto-mode' && isAutoModeGateEnabled() ) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, }) setHasExitedPlanMode(true) setNeedsPlanModeExitAttachment(true) autoModeStateModule?.setAutoModeActive(true) setAppState(prev => ({ ...prev, toolPermissionContext: stripDangerousPermissionsForAutoMode({ ...prev.toolPermissionContext, mode: 'auto', prePlanMode: undefined, }), })) onDone() toolUseConfirm.onAllow(updatedInput, [], acceptFeedback) return } // Handle keep-context options (goes through normal onAllow flow) // yes-resume-auto-mode falls through here when the auto mode gate is // disabled (e.g. circuit breaker fired after the dialog rendered). // Without this fallback the function would return without resolving the // dialog, leaving the query loop blocked and safety state corrupted. const keepContextModes: Record = { 'yes-accept-edits-keep-context': toolPermissionContext.isBypassPermissionsModeAvailable ? 'bypassPermissions' : 'acceptEdits', 'yes-default-keep-context': 'default', ...(feature('TRANSCRIPT_CLASSIFIER') ? { 'yes-resume-auto-mode': 'default' as const } : {}), } const keepContextMode = keepContextModes[value] if (keepContextMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, }) setHasExitedPlanMode(true) setNeedsPlanModeExitAttachment(true) onDone() toolUseConfirm.onAllow( updatedInput, buildPermissionUpdates(keepContextMode, allowedPrompts), acceptFeedback, ) return } // Handle standard approval options const standardModes: Record = { 'yes-bypass-permissions': 'bypassPermissions', 'yes-accept-edits': 'acceptEdits', } const standardMode = standardModes[value] if (standardMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, }) setHasExitedPlanMode(true) setNeedsPlanModeExitAttachment(true) onDone() toolUseConfirm.onAllow( updatedInput, buildPermissionUpdates(standardMode, allowedPrompts), acceptFeedback, ) return } // Handle 'no' - stay in plan mode if (value === 'no') { if (!trimmedFeedback && !hasImages) { // No feedback yet - user is still on the input field return } logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, }) // Convert pasted images to ImageBlockParam[] with resizing let imageBlocks: ImageBlockParam[] | undefined if (hasImages) { imageBlocks = await Promise.all( imageAttachments.map(async img => { const block: ImageBlockParam = { type: 'image', source: { type: 'base64', media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], data: img.content, }, } const resized = await maybeResizeAndDownsampleImageBlock(block) return resized.block }), ) } onDone() onReject() toolUseConfirm.onReject( trimmedFeedback || (hasImages ? '(See attached image)' : undefined), imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, ) } } const editor = getExternalEditor() const editorName = editor ? toIDEDisplayName(editor) : null // Sticky footer: when setStickyFooter is provided (fullscreen mode), the // Select options render in FullscreenLayout's `bottom` slot so they stay // visible while the user scrolls through a long plan. handleResponse is // wrapped in a ref so the JSX (set once per options/images change) can call // the latest closure without re-registering on every keystroke. React // reconciles the sticky-footer Select by type, preserving focus/input state. const handleResponseRef = useRef(handleResponse) handleResponseRef.current = handleResponse const handleCancelRef = useRef<() => void>(undefined) handleCancelRef.current = () => { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, }) onDone() onReject() toolUseConfirm.onReject() } const useStickyFooter = !isEmpty && !!setStickyFooter useLayoutEffect(() => { if (!useStickyFooter) return setStickyFooter( Would you like to proceed? { logEvent('tengu_plan_exit', { planLengthChars: 0, outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, }) onDone() onReject() toolUseConfirm.onReject() }} /> ) } return ( Here is Claude's plan: {currentPlan} {isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 && ( Requested permissions: {allowedPrompts.map((p, i) => ( {' '}· {p.tool}({PROMPT_PREFIX} {p.prompt}) ))} )} {!useStickyFooter && ( <> Claude has written up a plan and is ready to execute. Would you like to proceed?