feat(cli): remove Plan Mode from rotation when actively working (#19262)

This commit is contained in:
Jerop Kipruto
2026-02-17 12:36:59 -05:00
committed by GitHub
parent 8aca3068cf
commit fb32db5cd6
12 changed files with 193 additions and 77 deletions
+4 -3
View File
@@ -97,14 +97,14 @@ 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` |
| 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` |
@@ -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,
+4
View File
@@ -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.
+1 -1
View File
@@ -496,7 +496,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[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]:
+1
View File
@@ -155,6 +155,7 @@ const baseMockUiState = {
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black',
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
+95
View File
@@ -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!();
});
});
});
+15 -8
View File
@@ -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,
@@ -21,7 +21,7 @@ describe('ApprovalModeIndicator', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
isPlanEnabled={true}
allowPlanMode={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -52,7 +52,7 @@ describe('ApprovalModeIndicator', () => {
const { lastFrame } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
isPlanEnabled={true}
allowPlanMode={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
@@ -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<ApprovalModeIndicatorProps> = ({
approvalMode,
isPlanEnabled,
allowPlanMode,
}) => {
let textColor = '';
let textContent = '';
@@ -36,7 +36,7 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
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;
+1 -1
View File
@@ -346,7 +346,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{!showLoadingIndicator && (
@@ -130,6 +130,7 @@ export interface UIState {
messageQueue: string[];
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
quota: QuotaState;
currentModel: string;
@@ -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,
);
});
});
@@ -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;