mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
1043 lines
35 KiB
TypeScript
1043 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 '../../../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<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, 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)
|
|
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'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
|
|
}
|