From e04eef28b55ba1a68efdf0a54c27b82e5846547a Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 4 May 2026 20:35:02 -0400 Subject: [PATCH 01/12] feat(tui): add plan mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third mode option that cycles Safe → Auto → Plan → Safe. In Plan mode, the AI generates a structured plan that must be approved before any tool execution. Changes: 1. **Constants** - Add `PLAN` mode and system prompt variant for plan generation 2. **Footer.tsx** - Update to show "Plan" mode (purple/magenta color), handle 3-state cycle with Shift+Tab 3. **App.tsx** - Replace `autoExecute` boolean with `mode` state ('safe' | 'auto' | 'plan'), pass `mode` to Chat and Footer 4. **Chat.tsx** - When in Plan mode: - On user message, AI can use read-only tools (read_file, list_dir, grep_search, view_range) to research - Once research is done, AI generates a plan as markdown checklist (e.g., `- [ ] write_file("src/utils.ts") - Add new function`) - Show plan approval UI with 3 choices: "Auto", "Safe", "Cancel" - If Auto: execute all write tools in the plan automatically, updating checkboxes as each completes - If Safe: execute write tools one-by-one with individual approval prompts - If Cancel: abort, stay in Plan mode without executing write tools 5. **New PlanApproval component** - UI showing the generated plan with 3 action options (Auto/Safe/Cancel buttons). Escape key triggers Cancel. 6. **Tests** - Update existing tests, add new tests for plan mode Files to Modify: - `src/constants/mode.ts` - Add PLAN mode constant - `src/constants/prompt.ts` - Plan generation prompt - `src/components/Footer.tsx` - 3-state mode cycling, show current mode name - `src/components/App.tsx` - Replace autoExecute with mode state - `src/components/Chat.tsx` - Accept mode prop, handle plan generation and approval flow - `src/components/PlanApproval.tsx` - New component - `src/components/index.ts` - Export new component - `src/utils/tools.ts` - Categorize tools as read-only vs write --- src/components/App.test.tsx | 24 +-- src/components/App.tsx | 24 ++- src/components/Chat.test.tsx | 46 +++--- src/components/Chat.tsx | 212 +++++++++++++++++++++++++-- src/components/Footer.test.tsx | 28 +++- src/components/Footer.tsx | 26 +++- src/components/PlanApproval.test.tsx | 88 +++++++++++ src/components/PlanApproval.tsx | 60 ++++++++ src/components/index.ts | 1 + src/constants/index.ts | 1 + src/constants/mode.ts | 13 ++ src/constants/prompt.ts | 10 ++ src/utils/agents.ts | 7 +- src/utils/tools.test.ts | 14 +- src/utils/tools.ts | 12 +- 15 files changed, 487 insertions(+), 79 deletions(-) create mode 100644 src/components/PlanApproval.test.tsx create mode 100644 src/components/PlanApproval.tsx create mode 100644 src/constants/mode.ts diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx index 40f4649f..22d0021b 100644 --- a/src/components/App.test.tsx +++ b/src/components/App.test.tsx @@ -32,7 +32,7 @@ vi.mock('./Chat', () => ({ }: { model: string; onCommand: (command: string) => void; - autoExecute: boolean; + mode: string; }) => { capturedCallbacks.onCommand = onCommand; return {'>'}; @@ -56,14 +56,15 @@ vi.mock('./ModelPicker', () => ({ vi.mock('./Footer', () => ({ Footer: ({ - autoExecute, + mode, onToggleMode, }: { - autoExecute: boolean; + mode: string; onToggleMode: () => void; }) => { capturedCallbacks.onToggleMode = onToggleMode; - return Mode: {autoExecute ? 'Auto' : 'Safe'}; + const modeLabel = mode.charAt(0).toUpperCase() + mode.slice(1); + return Mode: {modeLabel}; }, })); @@ -127,25 +128,28 @@ describe('App', () => { expect(lastFrame()).not.toContain('ModelPicker'); }); - it('toggles autoExecute via Footer onToggleMode callback', async () => { + it('toggles mode via Footer onToggleMode callback (3-state cycle)', async () => { const { lastFrame, rerender } = render(); - // Initial state + // Initial state: Safe expect(lastFrame()).toContain('Mode: Safe'); - // Call the callback passed to Footer + // Call the callback passed to Footer - cycles to Auto capturedCallbacks.onToggleMode?.(); rerender(); await tick(); - - // Should show Auto mode expect(lastFrame()).toContain('Mode: Auto'); - // Call again to toggle back + // Call again - cycles to Plan capturedCallbacks.onToggleMode?.(); rerender(); await tick(); + expect(lastFrame()).toContain('Mode: Plan'); + // Call again - cycles back to Safe + capturedCallbacks.onToggleMode?.(); + rerender(); + await tick(); expect(lastFrame()).toContain('Mode: Safe'); }); }); diff --git a/src/components/App.tsx b/src/components/App.tsx index 6f198b4a..14fca156 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ import { Box } from 'ink'; import { useCallback, useState } from 'react'; +import { MODE } from '../constants'; import { config } from '../utils'; import { Chat } from './Chat'; import { Footer } from './Footer'; @@ -10,7 +11,7 @@ import { ModelPicker } from './ModelPicker'; export function App() { const [model, setModel] = useState(() => config.loadConfig().model); const [picking, setPicking] = useState(false); - const [autoExecute, setAutoExecute] = useState(false); + const [mode, setMode] = useState(MODE.NAME.SAFE); const handleCommand = useCallback((command: string) => { if (command === '/model') { @@ -39,17 +40,24 @@ export function App() { onCancel={handleCancel} /> ) : ( - + )}