diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index f8e76576b3..265fce8319 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -93,20 +93,20 @@ 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. | `Cmd + M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | -| Toggle Auto Edit (auto-accept edits) mode. | `Shift + Tab` | -| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` | -| Focus the shell input from the gemini input. | `Tab (no Shift)` | -| Focus the Gemini input from the shell input. | `Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl + L` | -| Restart the application. | `R` | +| Action | Keys | +| ----------------------------------------------------------------------------------------------------- | ---------------- | +| Toggle detailed error information. | `F12` | +| Toggle the full TODO list. | `Ctrl + T` | +| Show IDE context details. | `Ctrl + G` | +| Toggle Markdown rendering. | `Cmd + 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 a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + S` | +| Focus the shell input from the gemini input. | `Tab (no Shift)` | +| Focus the Gemini input from the shell input. | `Tab` | +| Clear the terminal screen and redraw the UI. | `Ctrl + L` | +| Restart the application. | `R` | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 21865123bf..455c9dba39 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -76,7 +76,7 @@ export enum Command { TOGGLE_MARKDOWN = 'app.toggleMarkdown', TOGGLE_COPY_MODE = 'app.toggleCopyMode', TOGGLE_YOLO = 'app.toggleYolo', - TOGGLE_AUTO_EDIT = 'app.toggleAutoEdit', + CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', SHOW_MORE_LINES = 'app.showMoreLines', FOCUS_SHELL_INPUT = 'app.focusShellInput', UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', @@ -246,7 +246,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }], - [Command.TOGGLE_AUTO_EDIT]: [{ key: 'tab', shift: true }], + [Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], @@ -352,7 +352,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.TOGGLE_MARKDOWN, Command.TOGGLE_COPY_MODE, Command.TOGGLE_YOLO, - Command.TOGGLE_AUTO_EDIT, + Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, Command.FOCUS_SHELL_INPUT, Command.UNFOCUS_SHELL_INPUT, @@ -437,7 +437,8 @@ export const commandDescriptions: Readonly> = { [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', - [Command.TOGGLE_AUTO_EDIT]: 'Toggle Auto Edit (auto-accept edits) mode.', + [Command.CYCLE_APPROVAL_MODE]: + 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', [Command.SHOW_MORE_LINES]: 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.', diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 74ad2f35b1..dc5d4613f7 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -136,7 +136,7 @@ vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); -vi.mock('./hooks/useAutoAcceptIndicator.js'); +vi.mock('./hooks/useApprovalModeIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); @@ -164,7 +164,7 @@ import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { useSessionStats } from './contexts/SessionContext.js'; @@ -236,7 +236,7 @@ describe('AppContainer State Management', () => { const mockedUseFolderTrust = useFolderTrust as Mock; const mockedUseIdeTrustListener = useIdeTrustListener as Mock; const mockedUseMessageQueue = useMessageQueue as Mock; - const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock; + const mockedUseApprovalModeIndicator = useApprovalModeIndicator as Mock; const mockedUseGitBranchName = useGitBranchName as Mock; const mockedUseVimMode = useVimMode as Mock; const mockedUseSessionStats = useSessionStats as Mock; @@ -335,7 +335,7 @@ describe('AppContainer State Management', () => { clearQueue: vi.fn(), getQueuedMessagesText: vi.fn().mockReturnValue(''), }); - mockedUseAutoAcceptIndicator.mockReturnValue(false); + mockedUseApprovalModeIndicator.mockReturnValue(false); mockedUseGitBranchName.mockReturnValue('main'); mockedUseVimMode.mockReturnValue({ isVimEnabled: false, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ee87b7719d..79c6fcd8af 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -102,7 +102,7 @@ import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; import type { SessionInfo } from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; -import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; +import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; import { useSessionStats } from './contexts/SessionContext.js'; import { useGitBranchName } from './hooks/useGitBranchName.js'; import { @@ -854,7 +854,7 @@ Logging in with Google... Restarting Gemini CLI to continue. ); // Auto-accept indicator - const showAutoAcceptIndicator = useAutoAcceptIndicator({ + const showApprovalModeIndicator = useApprovalModeIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, @@ -1590,7 +1590,7 @@ Logging in with Google... Restarting Gemini CLI to continue. activeHooks, messageQueue, queueErrorMessage, - showAutoAcceptIndicator, + showApprovalModeIndicator, currentModel, userTier, proQuotaRequest, @@ -1682,7 +1682,7 @@ Logging in with Google... Restarting Gemini CLI to continue. activeHooks, messageQueue, queueErrorMessage, - showAutoAcceptIndicator, + showApprovalModeIndicator, userTier, proQuotaRequest, validationRequest, diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx similarity index 57% rename from packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx rename to packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index d71d49d2f1..a5ddf5ac34 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -5,23 +5,32 @@ */ import { render } from '../../test-utils/render.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { describe, it, expect } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; -describe('AutoAcceptIndicator', () => { +describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).toContain('accepting edits'); - expect(output).toContain('(shift + tab to toggle)'); + expect(output).toContain('(shift + tab to cycle)'); + }); + + it('renders correctly for PLAN mode', () => { + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toContain('plan mode'); + expect(output).toContain('(shift + tab to cycle)'); }); it('renders correctly for YOLO mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).toContain('YOLO mode'); @@ -30,7 +39,7 @@ describe('AutoAcceptIndicator', () => { it('renders nothing for DEFAULT mode', () => { const { lastFrame } = render( - , + , ); const output = lastFrame(); expect(output).not.toContain('accepting edits'); diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx similarity index 74% rename from packages/cli/src/ui/components/AutoAcceptIndicator.tsx rename to packages/cli/src/ui/components/ApprovalModeIndicator.tsx index c72d366bb1..875cb0d84b 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -9,11 +9,11 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; -interface AutoAcceptIndicatorProps { +interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; } -export const AutoAcceptIndicator: React.FC = ({ +export const ApprovalModeIndicator: React.FC = ({ approvalMode, }) => { let textColor = ''; @@ -24,7 +24,12 @@ export const AutoAcceptIndicator: React.FC = ({ case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; textContent = 'accepting edits'; - subText = ' (shift + tab to toggle)'; + subText = ' (shift + tab to cycle)'; + break; + case ApprovalMode.PLAN: + textColor = theme.status.success; + textContent = 'plan mode'; + subText = ' (shift + tab to cycle)'; break; case ApprovalMode.YOLO: textColor = theme.status.error; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1a3e07c2b9..99a0237ac2 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -41,8 +41,8 @@ vi.mock('./HookStatusDisplay.js', () => ({ HookStatusDisplay: () => HookStatusDisplay, })); -vi.mock('./AutoAcceptIndicator.js', () => ({ - AutoAcceptIndicator: () => AutoAcceptIndicator, +vi.mock('./ApprovalModeIndicator.js', () => ({ + ApprovalModeIndicator: () => ApprovalModeIndicator, })); vi.mock('./ShellModeIndicator.js', () => ({ @@ -95,7 +95,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => ({ streamingState: null, contextFileNames: [], - showAutoAcceptIndicator: ApprovalMode.DEFAULT, + showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], showErrorDetails: false, constrainHeight: false, @@ -419,15 +419,15 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('InputPrompt'); }); - it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => { + it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => { const uiState = createMockUIState({ - showAutoAcceptIndicator: ApprovalMode.YOLO, + showApprovalModeIndicator: ApprovalMode.YOLO, shellModeActive: false, }); const { lastFrame } = renderComposer(uiState); - expect(lastFrame()).toContain('AutoAcceptIndicator'); + expect(lastFrame()).toContain('ApprovalModeIndicator'); }); it('shows ShellModeIndicator when shell mode is active', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index da1747c114..4fca6e8b0b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -8,7 +8,7 @@ import { useState } from 'react'; import { Box, useIsScreenReaderEnabled } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; import { StatusDisplay } from './StatusDisplay.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; @@ -42,7 +42,7 @@ export const Composer = () => { const [suggestionsVisible, setSuggestionsVisible] = useState(false); const isAlternateBuffer = useAlternateBuffer(); - const { showAutoAcceptIndicator } = uiState; + const { showApprovalModeIndicator } = uiState; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; @@ -92,9 +92,9 @@ export const Composer = () => { - {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && + {showApprovalModeIndicator !== ApprovalMode.DEFAULT && !uiState.shellModeActive && ( - + )} {uiState.shellModeActive && } {!uiState.renderMarkdown && } @@ -131,7 +131,7 @@ export const Composer = () => { commandContext={uiState.commandContext} shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} - approvalMode={showAutoAcceptIndicator} + approvalMode={showApprovalModeIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} focus={true} vimHandleInput={uiActions.vimHandleInput} diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 7d11c0826e..e3c06e02b6 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -90,7 +90,7 @@ export const INFORMATIVE_TIPS = [ 'Toggle the todo list display with Ctrl+T…', 'See full, untruncated responses with Ctrl+S…', 'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y…', - 'Toggle auto-accepting edits approval mode with Shift+Tab…', + 'Cycle through approval modes (Default, Plan, Auto-Edit) with Shift+Tab…', 'Toggle Markdown rendering (raw markdown mode) with Option+M…', 'Toggle shell mode by typing ! in an empty prompt…', 'Insert a newline with a backslash (\\) followed by Enter…', diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e768ef4796..feb3157e1a 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -106,7 +106,7 @@ export interface UIState { activeHooks: ActiveHook[]; messageQueue: string[]; queueErrorMessage: string | null; - showAutoAcceptIndicator: ApprovalMode; + showApprovalModeIndicator: ApprovalMode; // Quota-related state userTier: UserTierId | undefined; proQuotaRequest: ProQuotaDialogRequest | null; diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts similarity index 89% rename from packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts rename to packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index b61491b11e..3480791fe5 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -15,7 +15,7 @@ import { } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; -import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js'; +import { useApprovalModeIndicator } from './useApprovalModeIndicator.js'; import { Config, ApprovalMode } from '@google/gemini-cli-core'; import type { Config as ActualConfigType } from '@google/gemini-cli-core'; @@ -37,6 +37,7 @@ interface MockConfigInstanceShape { getApprovalMode: Mock<() => ApprovalMode>; setApprovalMode: Mock<(value: ApprovalMode) => void>; isYoloModeDisabled: Mock<() => boolean>; + isPlanEnabled: Mock<() => boolean>; isTrustedFolder: Mock<() => boolean>; getCoreTools: Mock<() => string[]>; getToolDiscoveryCommand: Mock<() => string | undefined>; @@ -55,7 +56,7 @@ interface MockConfigInstanceShape { type UseKeypressHandler = (key: Key) => void; -describe('useAutoAcceptIndicator', () => { +describe('useApprovalModeIndicator', () => { let mockConfigInstance: MockConfigInstanceShape; let capturedUseKeypressHandler: UseKeypressHandler; let mockedUseKeypress: MockedFunction; @@ -66,7 +67,9 @@ describe('useAutoAcceptIndicator', () => { ( Config as unknown as MockedFunction<() => MockConfigInstanceShape> ).mockImplementation(() => { - const instanceGetApprovalModeMock = vi.fn(); + const instanceGetApprovalModeMock = vi + .fn() + .mockReturnValue(ApprovalMode.DEFAULT); const instanceSetApprovalModeMock = vi.fn(); const instance: MockConfigInstanceShape = { @@ -77,6 +80,7 @@ describe('useAutoAcceptIndicator', () => { (value: ApprovalMode) => void >, isYoloModeDisabled: vi.fn().mockReturnValue(false), + isPlanEnabled: vi.fn().mockReturnValue(false), isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>, getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>, getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock< @@ -126,7 +130,7 @@ describe('useAutoAcceptIndicator', () => { it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }), @@ -138,7 +142,7 @@ describe('useAutoAcceptIndicator', () => { it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }), @@ -150,7 +154,7 @@ describe('useAutoAcceptIndicator', () => { it('should initialize with ApprovalMode.YOLO if config.getApprovalMode returns ApprovalMode.YOLO', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }), @@ -159,16 +163,17 @@ describe('useAutoAcceptIndicator', () => { expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); - it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => { + it('should cycle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }), ); expect(result.current).toBe(ApprovalMode.DEFAULT); + // Shift+Tab cycles to AUTO_EDIT act(() => { capturedUseKeypressHandler({ name: 'tab', @@ -188,22 +193,7 @@ describe('useAutoAcceptIndicator', () => { ); expect(result.current).toBe(ApprovalMode.YOLO); - act(() => { - capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); - }); - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.DEFAULT, - ); - expect(result.current).toBe(ApprovalMode.DEFAULT); - - act(() => { - capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); - }); - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.YOLO, - ); - expect(result.current).toBe(ApprovalMode.YOLO); - + // Shift+Tab cycles back to DEFAULT (since PLAN is disabled by default in mock) act(() => { capturedUseKeypressHandler({ name: 'tab', @@ -215,22 +205,67 @@ describe('useAutoAcceptIndicator', () => { ); expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + // Ctrl+Y toggles YOLO + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.YOLO, + ); + expect(result.current).toBe(ApprovalMode.YOLO); + + // Shift+Tab from YOLO jumps to AUTO_EDIT act(() => { capturedUseKeypressHandler({ name: 'tab', shift: true, } as Key); }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + 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 -> PLAN + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.PLAN, + ); + + // PLAN -> AUTO_EDIT + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + + // AUTO_EDIT -> DEFAULT + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.DEFAULT, ); - expect(result.current).toBe(ApprovalMode.DEFAULT); }); it('should not toggle if only one key or other keys combinations are pressed', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }), @@ -290,7 +325,7 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const { result, rerender } = renderHook( (props: { config: ActualConfigType; addItem: () => void }) => - useAutoAcceptIndicator(props), + useApprovalModeIndicator(props), { initialProps: { config: mockConfigInstance as unknown as ActualConfigType, @@ -324,7 +359,7 @@ describe('useAutoAcceptIndicator', () => { }); const mockAddItem = vi.fn(); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -354,7 +389,7 @@ describe('useAutoAcceptIndicator', () => { }); const mockAddItem = vi.fn(); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -382,7 +417,7 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); const mockAddItem = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -404,7 +439,7 @@ describe('useAutoAcceptIndicator', () => { ); const mockAddItem = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -433,7 +468,7 @@ describe('useAutoAcceptIndicator', () => { const mockAddItem = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -484,7 +519,7 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const mockAddItem = vi.fn(); const { result } = renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, addItem: mockAddItem, }), @@ -517,7 +552,7 @@ describe('useAutoAcceptIndicator', () => { const mockOnApprovalModeChange = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, onApprovalModeChange: mockOnApprovalModeChange, }), @@ -539,7 +574,7 @@ describe('useAutoAcceptIndicator', () => { const mockOnApprovalModeChange = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, onApprovalModeChange: mockOnApprovalModeChange, }), @@ -563,7 +598,7 @@ describe('useAutoAcceptIndicator', () => { const mockOnApprovalModeChange = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, onApprovalModeChange: mockOnApprovalModeChange, }), @@ -583,7 +618,7 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, }), ); @@ -604,7 +639,7 @@ describe('useAutoAcceptIndicator', () => { const mockOnApprovalModeChange = vi.fn(); renderHook(() => - useAutoAcceptIndicator({ + useApprovalModeIndicator({ config: mockConfigInstance as unknown as ActualConfigType, onApprovalModeChange: mockOnApprovalModeChange, }), diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts similarity index 66% rename from packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts rename to packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index aca0de57df..7fd25bd600 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -11,25 +11,24 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import type { HistoryItemWithoutId } from '../types.js'; import { MessageType } from '../types.js'; -export interface UseAutoAcceptIndicatorArgs { +export interface UseApprovalModeIndicatorArgs { config: Config; addItem?: (item: HistoryItemWithoutId, timestamp: number) => void; onApprovalModeChange?: (mode: ApprovalMode) => void; isActive?: boolean; } -export function useAutoAcceptIndicator({ +export function useApprovalModeIndicator({ config, addItem, onApprovalModeChange, isActive = true, -}: UseAutoAcceptIndicatorArgs): ApprovalMode { +}: UseApprovalModeIndicatorArgs): ApprovalMode { const currentConfigValue = config.getApprovalMode(); - const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] = - useState(currentConfigValue); + const [showApprovalMode, setApprovalMode] = useState(currentConfigValue); useEffect(() => { - setShowAutoAcceptIndicator(currentConfigValue); + setApprovalMode(currentConfigValue); }, [currentConfigValue]); useKeypress( @@ -56,18 +55,32 @@ export function useAutoAcceptIndicator({ config.getApprovalMode() === ApprovalMode.YOLO ? ApprovalMode.DEFAULT : ApprovalMode.YOLO; - } else if (keyMatchers[Command.TOGGLE_AUTO_EDIT](key)) { - nextApprovalMode = - config.getApprovalMode() === ApprovalMode.AUTO_EDIT - ? ApprovalMode.DEFAULT - : ApprovalMode.AUTO_EDIT; + } else if (keyMatchers[Command.CYCLE_APPROVAL_MODE](key)) { + const currentMode = config.getApprovalMode(); + switch (currentMode) { + case ApprovalMode.DEFAULT: + nextApprovalMode = config.isPlanEnabled() + ? ApprovalMode.PLAN + : ApprovalMode.AUTO_EDIT; + break; + case ApprovalMode.PLAN: + nextApprovalMode = ApprovalMode.AUTO_EDIT; + break; + case ApprovalMode.AUTO_EDIT: + nextApprovalMode = ApprovalMode.DEFAULT; + break; + case ApprovalMode.YOLO: + nextApprovalMode = ApprovalMode.AUTO_EDIT; + break; + default: + } } if (nextApprovalMode) { try { config.setApprovalMode(nextApprovalMode); // Update local state immediately for responsiveness - setShowAutoAcceptIndicator(nextApprovalMode); + setApprovalMode(nextApprovalMode); // Notify the central handler about the approval mode change onApprovalModeChange?.(nextApprovalMode); @@ -87,5 +100,5 @@ export function useAutoAcceptIndicator({ { isActive }, ); - return showAutoAcceptIndicator; + return showApprovalMode; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 0ea9e7000c..dc3003f8e4 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -336,7 +336,7 @@ describe('keyMatchers', () => { negative: [createKey('y'), createKey('y', { meta: true })], }, { - command: Command.TOGGLE_AUTO_EDIT, + command: Command.CYCLE_APPROVAL_MODE, positive: [createKey('tab', { shift: true })], negative: [createKey('tab')], },