From 13d86fbd7c3221f7ec25d2d426c35e6889526255 Mon Sep 17 00:00:00 2001 From: jnjpng Date: Tue, 10 Mar 2026 21:56:48 -0600 Subject: [PATCH] fix(tui): auto-approve plan mode in YOLO (#1345) --- src/cli/App.tsx | 147 +++++++++++------- .../cli/permission-mode-retry-wiring.test.ts | 40 ++++- 2 files changed, 128 insertions(+), 59 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3f5de6b..52c54a1 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -12451,13 +12451,23 @@ ${SYSTEM_REMINDER_CLOSE} const hasUsablePlan = planFileExists(fallbackPlanPath); if (mode !== "plan") { - if (hasUsablePlan) { - if (mode === "bypassPermissions") { - // User cycled to YOLO mode — auto-approve ExitPlanMode - // so they don't need to manually click through the approval. + if (mode === "bypassPermissions") { + if (hasUsablePlan) { + // YOLO mode with a plan file — auto-approve ExitPlanMode. handlePlanApprove(); return; } + // YOLO mode but no plan file yet — tell agent to write it first. + const planFilePath = activePlanPath ?? fallbackPlanPath; + const plansDir = join(homedir(), ".letta", "plans"); + handlePlanKeepPlanning( + `You must write your plan to a plan file before exiting plan mode.\n` + + (planFilePath ? `Plan file path: ${planFilePath}\n` : "") + + `Use a write tool to create your plan in ${plansDir}, then use ExitPlanMode to present the plan to the user.`, + ); + return; + } + if (hasUsablePlan) { // Other modes: keep approval flow alive and let user manually approve. return; } @@ -12580,28 +12590,33 @@ ${SYSTEM_REMINDER_CLOSE} [pendingApprovals, approvalResults, sendAllResults, refreshDerived], ); - const handleEnterPlanModeApprove = useCallback(async () => { - const currentIndex = approvalResults.length; - const approval = pendingApprovals[currentIndex]; - if (!approval) return; + const handleEnterPlanModeApprove = useCallback( + async (preserveMode: boolean = false) => { + const currentIndex = approvalResults.length; + const approval = pendingApprovals[currentIndex]; + if (!approval) return; - const isLast = currentIndex + 1 >= pendingApprovals.length; + const isLast = currentIndex + 1 >= pendingApprovals.length; - // Generate plan file path - const planFilePath = generatePlanFilePath(); - const applyPatchRelativePath = relative( - process.cwd(), - planFilePath, - ).replace(/\\/g, "/"); + // Generate plan file path + const planFilePath = generatePlanFilePath(); + const applyPatchRelativePath = relative( + process.cwd(), + planFilePath, + ).replace(/\\/g, "/"); - // Toggle plan mode on and store plan file path - permissionMode.setMode("plan"); - permissionMode.setPlanFilePath(planFilePath); - cacheLastPlanFilePath(planFilePath); - setUiPermissionMode("plan"); + // Store plan file path + permissionMode.setPlanFilePath(planFilePath); + cacheLastPlanFilePath(planFilePath); - // Get the tool return message from the implementation - const toolReturn = `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach. + if (!preserveMode) { + // Normal flow: switch to plan mode + permissionMode.setMode("plan"); + setUiPermissionMode("plan"); + } + + // Get the tool return message from the implementation + const toolReturn = `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach. In plan mode, you should: 1. Thoroughly explore the codebase to understand existing patterns @@ -12616,46 +12631,48 @@ Remember: DO NOT write or edit any files yet. This is a read-only exploration an Plan file path: ${planFilePath} If using apply_patch, use this exact relative patch path: ${applyPatchRelativePath}`; - const precomputedResult: ToolExecutionResult = { - toolReturn, - status: "success", - }; + const precomputedResult: ToolExecutionResult = { + toolReturn, + status: "success", + }; - // Update buffers with tool return - onChunk(buffersRef.current, { - message_type: "tool_return_message", - id: "dummy", - date: new Date().toISOString(), - tool_call_id: approval.toolCallId, - tool_return: toolReturn, - status: "success", - stdout: null, - stderr: null, - }); + // Update buffers with tool return + onChunk(buffersRef.current, { + message_type: "tool_return_message", + id: "dummy", + date: new Date().toISOString(), + tool_call_id: approval.toolCallId, + tool_return: toolReturn, + status: "success", + stdout: null, + stderr: null, + }); - setThinkingMessage(getRandomThinkingVerb()); - refreshDerived(); + setThinkingMessage(getRandomThinkingVerb()); + refreshDerived(); - const decision = { - type: "approve" as const, - approval, - precomputedResult, - }; + const decision = { + type: "approve" as const, + approval, + precomputedResult, + }; - if (isLast) { - setIsExecutingTool(true); - await sendAllResults(decision); - } else { - setApprovalResults((prev) => [...prev, decision]); - } - }, [ - pendingApprovals, - approvalResults, - sendAllResults, - refreshDerived, - setUiPermissionMode, - cacheLastPlanFilePath, - ]); + if (isLast) { + setIsExecutingTool(true); + await sendAllResults(decision); + } else { + setApprovalResults((prev) => [...prev, decision]); + } + }, + [ + pendingApprovals, + approvalResults, + sendAllResults, + refreshDerived, + setUiPermissionMode, + cacheLastPlanFilePath, + ], + ); const handleEnterPlanModeReject = useCallback(async () => { const currentIndex = approvalResults.length; @@ -12681,6 +12698,20 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa } }, [pendingApprovals, approvalResults, sendAllResults]); + // Guard EnterPlanMode: + // When in bypassPermissions (YOLO) mode, auto-approve EnterPlanMode and stay + // in YOLO — the agent gets plan instructions but keeps full permissions. + // The existing ExitPlanMode guard then auto-approves the exit too. + useEffect(() => { + const currentIndex = approvalResults.length; + const approval = pendingApprovals[currentIndex]; + if (approval?.toolName === "EnterPlanMode") { + if (permissionMode.getMode() === "bypassPermissions") { + handleEnterPlanModeApprove(true); + } + } + }, [pendingApprovals, approvalResults.length, handleEnterPlanModeApprove]); + // Live area shows only in-progress items // biome-ignore lint/correctness/useExhaustiveDependencies: staticItems.length and deferredCommitAt are intentional triggers to recompute when items are promoted to static or deferred commits complete const liveItems = useMemo(() => { diff --git a/src/tests/cli/permission-mode-retry-wiring.test.ts b/src/tests/cli/permission-mode-retry-wiring.test.ts index 7454a66..356d4bc 100644 --- a/src/tests/cli/permission-mode-retry-wiring.test.ts +++ b/src/tests/cli/permission-mode-retry-wiring.test.ts @@ -60,7 +60,7 @@ describe("permission mode retry wiring", () => { ); const enterPlanStart = source.indexOf( - "const handleEnterPlanModeApprove = useCallback(async () => {", + "const handleEnterPlanModeApprove = useCallback(", ); const enterPlanEnd = source.indexOf( "const handleEnterPlanModeReject = useCallback(async () => {", @@ -133,6 +133,44 @@ describe("permission mode retry wiring", () => { expect(segment).toContain("continue;"); }); + test("handleEnterPlanModeApprove supports preserveMode to stay in YOLO", () => { + const source = readAppSource(); + + const start = source.indexOf( + "const handleEnterPlanModeApprove = useCallback(", + ); + const end = source.indexOf( + "const handleEnterPlanModeReject = useCallback(async () => {", + ); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + + const segment = source.slice(start, end); + expect(segment).toContain("preserveMode: boolean = false"); + expect(segment).toContain("if (!preserveMode)"); + expect(segment).toContain('permissionMode.setMode("plan")'); + }); + + test("auto-approves EnterPlanMode in bypassPermissions mode", () => { + const source = readAppSource(); + + const guardStart = source.indexOf("Guard EnterPlanMode:"); + expect(guardStart).toBeGreaterThan(-1); + + const guardEnd = source.indexOf( + "// Live area shows only in-progress items", + guardStart, + ); + expect(guardEnd).toBeGreaterThan(guardStart); + + const segment = source.slice(guardStart, guardEnd); + expect(segment).toContain('approval?.toolName === "EnterPlanMode"'); + expect(segment).toContain( + 'permissionMode.getMode() === "bypassPermissions"', + ); + expect(segment).toContain("handleEnterPlanModeApprove(true)"); + }); + test("preserves saved plan path when approving ExitPlanMode after mode cycling", () => { const source = readAppSource();