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
+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;