* chore: rebrand user-facing copy to OpenClaude Replace lingering Claude Code branding in CLI, tips, and runtime UI with OpenClaude/openclaude, including the startup tip Gitlawb mention. Co-Authored-By: Claude GPT-5.4 <noreply@openclaude.dev> * chore: address branding-sweep review feedback - PermissionRequest.tsx: rebrand the two remaining "Claude needs your approval/permission" notifications to OpenClaude (review-artifact and generic tool permission paths). - main.tsx, teleport.tsx, session.tsx, WebFetchTool/utils.ts, skills/bundled/{debug,updateConfig}.ts: replace leftover `claude --…` CLI hints and "Claude Code" labels missed by the original sweep. - main.tsx: drop the inline gitlawb.com marketing copy from the stale-prompt tip; keep it a pure rebrand. - auth.ts: finish the half-rename so both `claude setup-token` and `claude auth login` references in the same error block now read `openclaude …`. - mcp/client.ts: keep `name: 'claude-code'` for MCP server allowlist compatibility (now explicit via comment) and replace the "Anthropic's agentic coding tool" description with an OpenClaude one. - MCPSettings.tsx: point the empty-server-list hint at https://github.com/Gitlawb/openclaude instead of code.claude.com. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: replace help link with OpenClaude repo URL Replace https://code.claude.com/docs/en/overview with https://github.com/Gitlawb/openclaude in the help screen. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: Claude GPT-5.4 <noreply@openclaude.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: OpenClaude <openclaude@gitlawb.com>
768 lines
34 KiB
TypeScript
768 lines
34 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 } 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<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 OpenClaude 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;
|
|
}
|