From fb32db5cd6ef9e7ca277b54e51c4b1042a0406e1 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Tue, 17 Feb 2026 12:36:59 -0500 Subject: [PATCH] feat(cli): remove Plan Mode from rotation when actively working (#19262) --- docs/cli/keyboard-shortcuts.md | 53 ++++++----- docs/cli/plan-mode.md | 4 + packages/cli/src/config/keyBindings.ts | 2 +- packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.test.tsx | 95 +++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 23 +++-- .../components/ApprovalModeIndicator.test.tsx | 4 +- .../ui/components/ApprovalModeIndicator.tsx | 6 +- packages/cli/src/ui/components/Composer.tsx | 2 +- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../ui/hooks/useApprovalModeIndicator.test.ts | 75 ++++++++------- .../src/ui/hooks/useApprovalModeIndicator.ts | 4 +- 12 files changed, 193 insertions(+), 77 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 938bc6ff7d..adc5b12c0a 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -96,31 +96,31 @@ available combinations. #### App Controls -| Action | Keys | -| ----------------------------------------------------------------------------------------------------- | ---------------- | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl + T` | -| Show IDE context details. | `Ctrl + G` | -| Toggle Markdown rendering. | `Alt + M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | -| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` | -| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | -| Toggle current background shell visibility. | `Ctrl + B` | -| Toggle background shell list. | `Ctrl + L` | -| Kill the active background shell. | `Ctrl + K` | -| Confirm selection in background shell list. | `Enter` | -| Dismiss background shell list. | `Esc` | -| Move focus from background shell to Gemini. | `Shift + Tab` | -| Move focus from background shell list to Gemini. | `Tab (no Shift)` | -| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` | -| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` | -| Move focus from Gemini to the active shell. | `Tab (no Shift)` | -| Move focus from the shell back to Gemini. | `Shift + Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Restart the application. | `R` | -| Suspend the CLI and move it to the background. | `Ctrl + Z` | +| Action | Keys | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Show IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Alt + M` | +| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | +| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | +| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift + Tab` | +| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl + O` | +| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` | +| Toggle current background shell visibility. | `Ctrl + B` | +| Toggle background shell list. | `Ctrl + L` | +| Kill the active background shell. | `Ctrl + K` | +| Confirm selection in background shell list. | `Enter` | +| Dismiss background shell list. | `Esc` | +| Move focus from background shell to Gemini. | `Shift + Tab` | +| Move focus from background shell list to Gemini. | `Tab (no Shift)` | +| Show warning when trying to move focus away from background shell. | `Tab (no Shift)` | +| Show warning when trying to move focus away from shell input. | `Tab (no Shift)` | +| Move focus from Gemini to the active shell. | `Tab (no Shift)` | +| Move focus from the shell back to Gemini. | `Shift + Tab` | +| Clear the terminal screen and redraw the UI. | `Ctrl + L` | +| Restart the application. | `R` | +| Suspend the CLI and move it to the background. | `Ctrl + Z` | @@ -138,7 +138,8 @@ available combinations. details when no completion/search interaction is active. The selected mode is remembered for future sessions. Full UI remains the default on first run, and single `Tab` keeps its existing completion/focus behavior. -- `Shift + Tab` (while typing in the prompt): Cycle approval modes. +- `Shift + Tab` (while typing in the prompt): Cycle approval modes: default, + auto-edit, and plan (skipped when agent is busy). - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. - `Esc` pressed twice quickly: Clear the input prompt if it is not empty, diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 2cf01ae354..1f283a63aa 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -62,6 +62,10 @@ You can enter Plan Mode in three ways: 1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes (`Default` -> `Auto-Edit` -> `Plan`). + + > **Note:** Plan Mode is automatically removed from the rotation when the + > agent is actively processing or showing confirmation dialogs. + 2. **Command:** Type `/plan` in the input box. 3. **Natural Language:** Ask the agent to "start a plan for...". The agent will then call the [`enter_plan_mode`] tool to switch modes. diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 94ceba1858..9833af93de 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -496,7 +496,7 @@ export const commandDescriptions: Readonly> = { [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', [Command.CYCLE_APPROVAL_MODE]: - 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', + 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.', [Command.SHOW_MORE_LINES]: 'Expand and collapse blocks of content when not in alternate buffer mode.', [Command.EXPAND_PASTE]: diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index de0afc9c50..829a7dd4ff 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -155,6 +155,7 @@ const baseMockUiState = { currentModel: 'gemini-pro', terminalBackgroundColor: 'black', cleanUiDetailsVisible: false, + allowPlanMode: true, activePtyId: undefined, backgroundShells: new Map(), backgroundShellHeight: 0, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 065195a14a..64e80633e0 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -88,6 +88,7 @@ import ansiEscapes from 'ansi-escapes'; import { mergeSettings, type LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; +import { StreamingState } from './types.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, @@ -2979,4 +2980,98 @@ describe('AppContainer State Management', () => { }, ); }); + + describe('Plan Mode Availability', () => { + it('should allow plan mode when enabled and idle', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [], + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + }); + unmount!(); + }); + + it('should NOT allow plan mode when disabled in config', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(false); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [], + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); + }); + + it('should NOT allow plan mode when streaming', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: StreamingState.Responding, + pendingHistoryItems: [], + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); + }); + + it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { + vi.spyOn(mockConfig, 'isPlanEnabled').mockReturnValue(true); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: StreamingState.Idle, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + name: 'test_tool', + status: CoreToolCallStatus.AwaitingApproval, + }, + ], + }, + ], + }); + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); + }); + }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9b3714ca87..446e737394 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1087,14 +1087,6 @@ Logging in with Google... Restarting Gemini CLI to continue. ], ); - // Auto-accept indicator - const showApprovalModeIndicator = useApprovalModeIndicator({ - config, - addItem: historyManager.addItem, - onApprovalModeChange: handleApprovalModeChangeWithUiReveal, - isActive: !embeddedShellFocused, - }); - const { isMcpReady } = useMcpStatus(config); const { @@ -1897,6 +1889,19 @@ Logging in with Google... Restarting Gemini CLI to continue. !!validationRequest || !!customDialog; + const allowPlanMode = + config.isPlanEnabled() && + streamingState === StreamingState.Idle && + !hasPendingActionRequired; + + const showApprovalModeIndicator = useApprovalModeIndicator({ + config, + addItem: historyManager.addItem, + onApprovalModeChange: handleApprovalModeChangeWithUiReveal, + isActive: !embeddedShellFocused, + allowPlanMode, + }); + const isPassiveShortcutsHelpState = isInputActive && streamingState === StreamingState.Idle && @@ -2031,6 +2036,7 @@ Logging in with Google... Restarting Gemini CLI to continue. messageQueue, queueErrorMessage, showApprovalModeIndicator, + allowPlanMode, currentModel, quota: { userTier, @@ -2145,6 +2151,7 @@ Logging in with Google... Restarting Gemini CLI to continue. messageQueue, queueErrorMessage, showApprovalModeIndicator, + allowPlanMode, userTier, quotaStats, proQuotaRequest, diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index 972aa586a0..cebe0cc75b 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -21,7 +21,7 @@ describe('ApprovalModeIndicator', () => { const { lastFrame } = render( , ); expect(lastFrame()).toMatchSnapshot(); @@ -52,7 +52,7 @@ describe('ApprovalModeIndicator', () => { const { lastFrame } = render( , ); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index ef5ae2caad..b5a981ac7a 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -11,7 +11,7 @@ import { ApprovalMode } from '@google/gemini-cli-core'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; - isPlanEnabled?: boolean; + allowPlanMode?: boolean; } export const APPROVAL_MODE_TEXT = { @@ -26,7 +26,7 @@ export const APPROVAL_MODE_TEXT = { export const ApprovalModeIndicator: React.FC = ({ approvalMode, - isPlanEnabled, + allowPlanMode, }) => { let textColor = ''; let textContent = ''; @@ -36,7 +36,7 @@ export const ApprovalModeIndicator: React.FC = ({ case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; textContent = APPROVAL_MODE_TEXT.AUTO_EDIT; - subText = isPlanEnabled + subText = allowPlanMode ? APPROVAL_MODE_TEXT.HINT_SWITCH_TO_PLAN_MODE : APPROVAL_MODE_TEXT.HINT_SWITCH_TO_MANUAL_MODE; break; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d3193d75dc..fd30e33858 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -346,7 +346,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showApprovalIndicator && ( )} {!showLoadingIndicator && ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 159ffd21fc..e64b5a1f99 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -130,6 +130,7 @@ export interface UIState { messageQueue: string[]; queueErrorMessage: string | null; showApprovalModeIndicator: ApprovalMode; + allowPlanMode: boolean; // Quota-related state quota: QuotaState; currentModel: string; diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index bdd99be61d..08ddd362f7 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -236,41 +236,6 @@ describe('useApprovalModeIndicator', () => { expect(result.current).toBe(ApprovalMode.AUTO_EDIT); }); - it('should cycle through DEFAULT -> AUTO_EDIT -> PLAN -> DEFAULT when plan is enabled', () => { - mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); - mockConfigInstance.isPlanEnabled.mockReturnValue(true); - renderHook(() => - useApprovalModeIndicator({ - config: mockConfigInstance as unknown as ActualConfigType, - addItem: vi.fn(), - }), - ); - - // DEFAULT -> AUTO_EDIT - act(() => { - capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); - }); - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.AUTO_EDIT, - ); - - // AUTO_EDIT -> PLAN - act(() => { - capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); - }); - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, - ); - - // PLAN -> DEFAULT - act(() => { - capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); - }); - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.DEFAULT, - ); - }); - it('should not toggle if only one key or other keys combinations are pressed', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); renderHook(() => @@ -729,4 +694,44 @@ describe('useApprovalModeIndicator', () => { ApprovalMode.AUTO_EDIT, ); }); + + it('should cycle to PLAN when allowPlanMode is true', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); + + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: vi.fn(), + allowPlanMode: true, + }), + ); + + // AUTO_EDIT -> PLAN + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.PLAN, + ); + }); + + it('should cycle to DEFAULT when allowPlanMode is false', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); + + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: vi.fn(), + allowPlanMode: false, + }), + ); + + // AUTO_EDIT -> DEFAULT + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.DEFAULT, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index d12afb1206..1b5076027f 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -20,6 +20,7 @@ export interface UseApprovalModeIndicatorArgs { addItem?: (item: HistoryItemWithoutId, timestamp: number) => void; onApprovalModeChange?: (mode: ApprovalMode) => void; isActive?: boolean; + allowPlanMode?: boolean; } export function useApprovalModeIndicator({ @@ -27,6 +28,7 @@ export function useApprovalModeIndicator({ addItem, onApprovalModeChange, isActive = true, + allowPlanMode = false, }: UseApprovalModeIndicatorArgs): ApprovalMode { const currentConfigValue = config.getApprovalMode(); const [showApprovalMode, setApprovalMode] = useState(currentConfigValue); @@ -75,7 +77,7 @@ export function useApprovalModeIndicator({ nextApprovalMode = ApprovalMode.AUTO_EDIT; break; case ApprovalMode.AUTO_EDIT: - nextApprovalMode = config.isPlanEnabled() + nextApprovalMode = allowPlanMode ? ApprovalMode.PLAN : ApprovalMode.DEFAULT; break;