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 } from '../../../ink/events/keyboard-event.js'; import { Box, Text } from '../../../ink.js'; 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>({}); 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?