Files
claude-code/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx
18243133 b67e9f9d38 Fix/plan paste fixes (#1238)
* fix: 降低 paste 检测阈值,修复非 bracketed-paste 终端粘贴文本损坏

非 bracketed-paste 终端下,短粘贴(<800 chars)的 stdin chunk 作为独立
keystroke 走 useTextInput.onInput 路径,闭包中 cursor 未刷新导致多次插入
竞态。现将 ≥3 字符的非特殊键输入纳入 paste 累积模式,绕过逐 chunk 处理。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* @
fix: Plan模式三处缺陷修复 — ExploreAgent可用性 + 弹窗一致性 + 方案文件保护

1. areExplorePlanAgentsEnabled()移除GrowthBook A/B实验依赖(tengu_amber_stoat),
   始终返回true,确保Explore/Plan agent在BUILTIN_EXPLORE_PLAN_AGENTS开启时始终可用

2. ExitPlanMode clear-context路径补setNeedsPlanModeExitAttachment(true),
   确保清除上下文退出Plan模式后生成plan_mode_exit附件

3. Plan mode full/sparse指令强化Plan文件读取要求:
   "can read" -> "MUST use FileRead to read first before any changes",
   新增"do NOT overwrite"禁止覆盖,Phase 1指令强化并行Explore Agent引导

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@

---------

Co-authored-by: psj88520 <qq18243133@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:57:15 +08:00

899 lines
35 KiB
TypeScript

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 '@claude-code-best/builtin-tools/tools/AgentTool/constants.js';
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js';
import type { AllowedPrompt } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js';
import { TEAM_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/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<number, PastedContent>>({});
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 as { input_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number },
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<void> {
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);
setNeedsPlanModeExitAttachment(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<string, PermissionMode> = {
'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<string, PermissionMode> = {
'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(
<Box
flexDirection="column"
borderStyle="round"
borderColor="planMode"
borderLeft={false}
borderRight={false}
borderBottom={false}
paddingX={1}
>
<Text dimColor>Would you like to proceed?</Text>
<Box marginTop={1}>
<Select
options={options}
onChange={v => void handleResponseRef.current(v)}
onCancel={() => handleCancelRef.current?.()}
onImagePaste={onImagePaste}
pastedContents={pastedContents}
onRemoveImage={onRemoveImage}
/>
</Box>
{editorName && (
<Box flexDirection="row" gap={1} marginTop={1}>
<Text dimColor>ctrl-g to edit in </Text>
<Text bold dimColor>
{editorName}
</Text>
{isV2 && planFilePath && <Text dimColor> · {getDisplayPath(planFilePath)}</Text>}
{showSaveMessage && (
<>
<Text dimColor>{' · '}</Text>
<Text color="success">{figures.tick}Plan saved!</Text>
</>
)}
</Box>
)}
</Box>,
);
return () => setStickyFooter(null);
// onImagePaste/onRemoveImage are stable (useCallback/useRef-backed above)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useStickyFooter, setStickyFooter, options, pastedContents, editorName, isV2, planFilePath, showSaveMessage]);
// Simplified UI for empty plans
if (isEmpty) {
function handleEmptyPlanResponse(value: 'yes' | 'no'): void {
if (value === 'yes') {
logEvent('tengu_plan_exit', {
planLengthChars: 0,
outcome: 'yes-default' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(),
planStructureVariant,
});
if (feature('TRANSCRIPT_CLASSIFIER')) {
const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false;
if (autoWasUsedDuringPlan) {
autoModeStateModule?.setAutoModeActive(false);
setNeedsAutoModeExitAttachment(true);
setAppState(prev => ({
...prev,
toolPermissionContext: {
...restoreDangerousPermissions(prev.toolPermissionContext),
prePlanMode: undefined,
},
}));
}
}
setHasExitedPlanMode(true);
setNeedsPlanModeExitAttachment(true);
onDone();
toolUseConfirm.onAllow({}, [{ type: 'setMode', mode: 'default', destination: 'session' }]);
} else {
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 (
<PermissionDialog color="planMode" title="Exit plan mode?" workerBadge={workerBadge}>
<Box flexDirection="column" paddingX={1} marginTop={1}>
<Text>Claude wants to exit plan mode</Text>
<Box marginTop={1}>
<Select
options={[
{ label: 'Yes', value: 'yes' as const },
{ label: 'No', value: 'no' as const },
]}
onChange={handleEmptyPlanResponse}
onCancel={() => {
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();
}}
/>
</Box>
</Box>
</PermissionDialog>
);
}
return (
<Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<PermissionDialog color="planMode" title="Ready to code?" innerPaddingX={0} workerBadge={workerBadge}>
<Box flexDirection="column" marginTop={1}>
<Box paddingX={1} flexDirection="column">
<Text>Here is Claude&apos;s plan:</Text>
</Box>
<Box
borderColor="subtle"
borderStyle="dashed"
flexDirection="column"
borderLeft={false}
borderRight={false}
paddingX={1}
marginBottom={1}
// Necessary for Windows Terminal to render properly
overflow="hidden"
>
<Markdown>{currentPlan}</Markdown>
</Box>
<Box flexDirection="column" paddingX={1}>
<PermissionRuleExplanation permissionResult={toolUseConfirm.permissionResult} toolType="tool" />
{isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
<Text bold>Requested permissions:</Text>
{allowedPrompts.map((p, i) => (
<Text key={i} dimColor>
{' '}· {p.tool}({PROMPT_PREFIX} {p.prompt})
</Text>
))}
</Box>
)}
{!useStickyFooter && (
<>
<Text dimColor>Claude has written up a plan and is ready to execute. Would you like to proceed?</Text>
<Box marginTop={1}>
<Select
options={options}
onChange={handleResponse}
onCancel={() => handleCancelRef.current?.()}
onImagePaste={onImagePaste}
pastedContents={pastedContents}
onRemoveImage={onRemoveImage}
/>
</Box>
</>
)}
</Box>
</Box>
</PermissionDialog>
{!useStickyFooter && editorName && (
<Box flexDirection="row" gap={1} paddingX={1} marginTop={1}>
<Box>
<Text dimColor>ctrl-g to edit in </Text>
<Text bold dimColor>
{editorName}
</Text>
{isV2 && planFilePath && <Text dimColor> · {getDisplayPath(planFilePath)}</Text>}
</Box>
{showSaveMessage && (
<Box>
<Text dimColor>{' · '}</Text>
<Text color="success">{figures.tick}Plan saved!</Text>
</Box>
)}
</Box>
)}
</Box>
);
}
/** @internal Exported for testing. */
export function buildPlanApprovalOptions({
showClearContext,
showUltraplan,
usedPercent,
isAutoModeAvailable,
isBypassPermissionsModeAvailable,
onFeedbackChange,
}: {
showClearContext: boolean;
showUltraplan: boolean;
usedPercent: number | null;
isAutoModeAvailable: boolean | undefined;
isBypassPermissionsModeAvailable: boolean | undefined;
onFeedbackChange: (v: string) => void;
}): OptionWithDescription<ResponseValue>[] {
const options: OptionWithDescription<ResponseValue>[] = [];
const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : '';
if (showClearContext) {
if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) {
options.push({
label: `Yes, clear context${usedLabel} and use auto mode`,
value: 'yes-auto-clear-context',
});
} else if (isBypassPermissionsModeAvailable) {
options.push({
label: `Yes, clear context${usedLabel} and bypass permissions`,
value: 'yes-bypass-permissions',
});
} else {
options.push({
label: `Yes, clear context${usedLabel} and auto-accept edits`,
value: 'yes-accept-edits',
});
}
}
// Slot 2: keep-context with elevated mode (same priority: auto > bypass > edits).
if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) {
options.push({
label: 'Yes, and use auto mode',
value: 'yes-resume-auto-mode',
});
} else if (isBypassPermissionsModeAvailable) {
options.push({
label: 'Yes, and bypass permissions',
value: 'yes-accept-edits-keep-context',
});
} else {
options.push({
label: 'Yes, auto-accept edits',
value: 'yes-accept-edits-keep-context',
});
}
options.push({
label: 'Yes, manually approve edits',
value: 'yes-default-keep-context',
});
if (showUltraplan) {
options.push({
label: 'No, refine with Ultraplan on Claude Code on the web',
value: 'ultraplan',
});
}
options.push({
type: 'input',
label: 'No, keep planning',
value: 'no',
placeholder: 'Tell Claude what to change',
description: 'shift+tab to approve with this feedback',
onChange: onFeedbackChange,
});
return options;
}
function getContextUsedPercent(
usage:
| {
input_tokens: number;
cache_creation_input_tokens?: number | null;
cache_read_input_tokens?: number | null;
}
| undefined,
permissionMode: PermissionMode,
): number | null {
if (!usage) return null;
const runtimeModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel: getMainLoopModel(),
exceeds200kTokens: false,
});
const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas());
const { used } = calculateContextPercentages(
{
input_tokens: usage.input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
},
contextWindowSize,
);
return used;
}