diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 5f5ae4b8dc..00586c7081 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -602,6 +602,7 @@ const mockUIActions: UIActions = { getPreferredEditor: vi.fn(), clearAccountSuspension: vi.fn(), setVoiceModeEnabled: vi.fn(), + cycleApprovalMode: vi.fn(), }; import { type TextBuffer } from '../ui/components/shared/text-buffer.js'; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 313f377b02..b167c0b64e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2296,13 +2296,14 @@ Logging in with Google... Restarting Gemini CLI to continue. streamingState === StreamingState.Idle && !hasPendingActionRequired; - const showApprovalModeIndicator = useApprovalModeIndicator({ - config, - addItem: historyManager.addItem, - onApprovalModeChange: handleApprovalModeChangeWithUiReveal, - isActive: !embeddedShellFocused, - allowPlanMode, - }); + const { approvalMode: showApprovalModeIndicator, cycleApprovalMode } = + useApprovalModeIndicator({ + config, + addItem: historyManager.addItem, + onApprovalModeChange: handleApprovalModeChangeWithUiReveal, + isActive: !embeddedShellFocused, + allowPlanMode, + }); useRunEventNotifications({ notificationsEnabled, @@ -2789,6 +2790,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setVoiceModeEnabled: (value: boolean) => { setVoiceModeEnabled(value); }, + cycleApprovalMode, }), [ handleThemeSelect, @@ -2848,6 +2850,7 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager, getPreferredEditor, setVoiceModeEnabled, + cycleApprovalMode, ], ); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index 1b2decbe16..c03ebb5092 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -4,21 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; +import { useMouseClick } from '../hooks/useMouseClick.js'; + +vi.mock('../hooks/useMouseClick.js', () => ({ + useMouseClick: vi.fn(), +})); describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( { }); it('renders correctly for PLAN mode', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( , ); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { - const { lastFrame } = await render( + const { lastFrame } = await renderWithProviders( { ); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders correctly when mouse mode is enabled', async () => { + const { lastFrame } = await renderWithProviders( + , + { + uiState: { mouseMode: true }, + }, + ); + expect(lastFrame()).toContain('click or Shift+Tab'); + }); + + it('calls cycleApprovalMode when clicked', async () => { + const cycleApprovalMode = vi.fn(); + let clickHandler: () => void = () => {}; + vi.mocked(useMouseClick).mockImplementation((_ref, handler) => { + clickHandler = handler as () => void; + }); + + await renderWithProviders( + , + { + uiActions: { cycleApprovalMode }, + }, + ); + + clickHandler(); + expect(cycleApprovalMode).toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index 7e8f388c82..3309a1b5af 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -4,27 +4,40 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; +import { useRef } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; import { formatCommand } from '../key/keybindingUtils.js'; import { Command } from '../key/keyBindings.js'; +import { useMouseClick } from '../hooks/useMouseClick.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; allowPlanMode?: boolean; } -export const ApprovalModeIndicator: React.FC = ({ +export const ApprovalModeIndicator: FC = ({ approvalMode, allowPlanMode, }) => { + const { mouseMode } = useUIState(); + const { cycleApprovalMode } = useUIActions(); + const boxRef = useRef(null); + + useMouseClick(boxRef, () => { + cycleApprovalMode(); + }); + let textColor = ''; let textContent = ''; let subText = ''; const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE); + const clickHint = mouseMode ? 'click or ' : ''; const yoloHint = formatCommand(Command.TOGGLE_YOLO); switch (approvalMode) { @@ -32,13 +45,13 @@ export const ApprovalModeIndicator: React.FC = ({ textColor = theme.status.warning; textContent = 'auto-accept edits'; subText = allowPlanMode - ? `${cycleHint} to plan` - : `${cycleHint} to manual`; + ? `${clickHint}${cycleHint} to plan` + : `${clickHint}${cycleHint} to manual`; break; case ApprovalMode.PLAN: textColor = theme.status.success; textContent = 'plan'; - subText = `${cycleHint} to manual`; + subText = `${clickHint}${cycleHint} to manual`; break; case ApprovalMode.YOLO: textColor = theme.status.error; @@ -49,12 +62,12 @@ export const ApprovalModeIndicator: React.FC = ({ default: textColor = theme.text.accent; textContent = ''; - subText = `${cycleHint} to accept edits`; + subText = `${clickHint}${cycleHint} to accept edits`; break; } return ( - + {textContent ? textContent : null} {subText ? ( diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index cb89758300..540efd574f 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -97,6 +97,7 @@ export interface UIActions { getPreferredEditor: () => EditorType | undefined; clearAccountSuspension: () => void; setVoiceModeEnabled: (value: boolean) => void; + cycleApprovalMode: () => void; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index 9771d10d83..c5c24fb5f3 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -146,7 +146,7 @@ describe('useApprovalModeIndicator', () => { addItem: vi.fn(), }), ); - expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + expect(result.current.approvalMode).toBe(ApprovalMode.AUTO_EDIT); expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); @@ -158,7 +158,7 @@ describe('useApprovalModeIndicator', () => { addItem: vi.fn(), }), ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); @@ -170,7 +170,7 @@ describe('useApprovalModeIndicator', () => { addItem: vi.fn(), }), ); - expect(result.current).toBe(ApprovalMode.YOLO); + expect(result.current.approvalMode).toBe(ApprovalMode.YOLO); expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); @@ -182,7 +182,7 @@ describe('useApprovalModeIndicator', () => { addItem: vi.fn(), }), ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); // Shift+Tab cycles to AUTO_EDIT act(() => { @@ -194,7 +194,7 @@ describe('useApprovalModeIndicator', () => { expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, ); - expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + expect(result.current.approvalMode).toBe(ApprovalMode.AUTO_EDIT); act(() => { capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); @@ -202,7 +202,7 @@ describe('useApprovalModeIndicator', () => { expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.YOLO, ); - expect(result.current).toBe(ApprovalMode.YOLO); + expect(result.current.approvalMode).toBe(ApprovalMode.YOLO); // Shift+Tab cycles back to AUTO_EDIT (from YOLO) act(() => { @@ -214,7 +214,7 @@ describe('useApprovalModeIndicator', () => { expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, ); - expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + expect(result.current.approvalMode).toBe(ApprovalMode.AUTO_EDIT); // Ctrl+Y toggles YOLO act(() => { @@ -223,7 +223,7 @@ describe('useApprovalModeIndicator', () => { expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.YOLO, ); - expect(result.current).toBe(ApprovalMode.YOLO); + expect(result.current.approvalMode).toBe(ApprovalMode.YOLO); // Shift+Tab from YOLO jumps to AUTO_EDIT act(() => { @@ -235,7 +235,7 @@ describe('useApprovalModeIndicator', () => { expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( ApprovalMode.AUTO_EDIT, ); - expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + expect(result.current.approvalMode).toBe(ApprovalMode.AUTO_EDIT); }); it('should not toggle if only one key or other keys combinations are pressed', async () => { @@ -309,7 +309,7 @@ describe('useApprovalModeIndicator', () => { }, }, ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); @@ -317,7 +317,7 @@ describe('useApprovalModeIndicator', () => { config: mockConfigInstance as unknown as ActualConfigType, addItem: vi.fn(), }); - expect(result.current).toBe(ApprovalMode.AUTO_EDIT); + expect(result.current.approvalMode).toBe(ApprovalMode.AUTO_EDIT); expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(3); }); @@ -341,7 +341,7 @@ describe('useApprovalModeIndicator', () => { }), ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); act(() => { capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); @@ -371,7 +371,7 @@ describe('useApprovalModeIndicator', () => { }), ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); act(() => { capturedUseKeypressHandler({ @@ -504,7 +504,7 @@ describe('useApprovalModeIndicator', () => { }), ); - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); act(() => { capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); @@ -521,7 +521,7 @@ describe('useApprovalModeIndicator', () => { expect.any(Number), ); // The mode should not change - expect(result.current).toBe(ApprovalMode.DEFAULT); + expect(result.current.approvalMode).toBe(ApprovalMode.DEFAULT); }); it('should show admin error message when YOLO mode is disabled by admin', async () => { diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index 1dd6c6468e..70d249819c 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { ApprovalMode, type Config, @@ -23,13 +23,19 @@ export interface UseApprovalModeIndicatorArgs { allowPlanMode?: boolean; } +export interface UseApprovalModeIndicatorResult { + approvalMode: ApprovalMode; + cycleApprovalMode: () => void; + toggleYolo: () => void; +} + export function useApprovalModeIndicator({ config, addItem, onApprovalModeChange, isActive = true, allowPlanMode = false, -}: UseApprovalModeIndicatorArgs): ApprovalMode { +}: UseApprovalModeIndicatorArgs): UseApprovalModeIndicatorResult { const keyMatchers = useKeyMatchers(); const currentConfigValue = config.getApprovalMode(); const [showApprovalMode, setApprovalMode] = useState(currentConfigValue); @@ -38,84 +44,107 @@ export function useApprovalModeIndicator({ setApprovalMode(currentConfigValue); }, [currentConfigValue]); - useKeypress( - (key) => { - let nextApprovalMode: ApprovalMode | undefined; - - if (keyMatchers[Command.TOGGLE_YOLO](key)) { - if ( - config.isYoloModeDisabled() && - config.getApprovalMode() !== ApprovalMode.YOLO - ) { - if (addItem) { - let text = - 'You cannot enter YOLO mode since it is disabled in your settings.'; - const adminSettings = config.getRemoteAdminSettings(); - const hasSettings = - adminSettings && Object.keys(adminSettings).length > 0; - if (hasSettings && !adminSettings.strictModeDisabled) { - text = getAdminErrorMessage('YOLO mode', config); - } - - addItem( - { - type: MessageType.WARNING, - text, - }, - Date.now(), - ); - } - return; + const toggleYolo = useCallback(() => { + if ( + config.isYoloModeDisabled() && + config.getApprovalMode() !== ApprovalMode.YOLO + ) { + if (addItem) { + let text = + 'You cannot enter YOLO mode since it is disabled in your settings.'; + const adminSettings = config.getRemoteAdminSettings(); + const hasSettings = + adminSettings && Object.keys(adminSettings).length > 0; + if (hasSettings && !adminSettings.strictModeDisabled) { + text = getAdminErrorMessage('YOLO mode', config); } - nextApprovalMode = - config.getApprovalMode() === ApprovalMode.YOLO - ? ApprovalMode.DEFAULT - : ApprovalMode.YOLO; - } else if (keyMatchers[Command.CYCLE_APPROVAL_MODE](key)) { - const currentMode = config.getApprovalMode(); - switch (currentMode) { - case ApprovalMode.DEFAULT: - nextApprovalMode = ApprovalMode.AUTO_EDIT; - break; - case ApprovalMode.AUTO_EDIT: - nextApprovalMode = allowPlanMode - ? ApprovalMode.PLAN - : ApprovalMode.DEFAULT; - break; - case ApprovalMode.PLAN: - nextApprovalMode = ApprovalMode.DEFAULT; - break; - case ApprovalMode.YOLO: - nextApprovalMode = ApprovalMode.AUTO_EDIT; - break; - default: + + addItem( + { + type: MessageType.WARNING, + text, + }, + Date.now(), + ); + } + return; + } + const nextApprovalMode = + config.getApprovalMode() === ApprovalMode.YOLO + ? ApprovalMode.DEFAULT + : ApprovalMode.YOLO; + + try { + config.setApprovalMode(nextApprovalMode); + setApprovalMode(nextApprovalMode); + onApprovalModeChange?.(nextApprovalMode); + } catch (e) { + if (addItem) { + addItem( + { + type: MessageType.INFO, + text: e instanceof Error ? e.message : String(e), + }, + Date.now(), + ); + } + } + }, [config, addItem, onApprovalModeChange]); + + const cycleApprovalMode = useCallback(() => { + const currentMode = config.getApprovalMode(); + let nextApprovalMode: ApprovalMode | undefined; + switch (currentMode) { + case ApprovalMode.DEFAULT: + nextApprovalMode = ApprovalMode.AUTO_EDIT; + break; + case ApprovalMode.AUTO_EDIT: + nextApprovalMode = allowPlanMode + ? ApprovalMode.PLAN + : ApprovalMode.DEFAULT; + break; + case ApprovalMode.PLAN: + nextApprovalMode = ApprovalMode.DEFAULT; + break; + case ApprovalMode.YOLO: + nextApprovalMode = ApprovalMode.AUTO_EDIT; + break; + default: + } + + if (nextApprovalMode) { + try { + config.setApprovalMode(nextApprovalMode); + setApprovalMode(nextApprovalMode); + onApprovalModeChange?.(nextApprovalMode); + } catch (e) { + if (addItem) { + addItem( + { + type: MessageType.INFO, + text: e instanceof Error ? e.message : String(e), + }, + Date.now(), + ); } } + } + }, [config, allowPlanMode, onApprovalModeChange, addItem]); - if (nextApprovalMode) { - try { - config.setApprovalMode(nextApprovalMode); - // Update local state immediately for responsiveness - setApprovalMode(nextApprovalMode); - - // Notify the central handler about the approval mode change - onApprovalModeChange?.(nextApprovalMode); - } catch (e) { - if (addItem) { - addItem( - { - type: MessageType.INFO, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - text: (e as Error).message, - }, - Date.now(), - ); - } - } + useKeypress( + (key) => { + if (keyMatchers[Command.TOGGLE_YOLO](key)) { + toggleYolo(); + } else if (keyMatchers[Command.CYCLE_APPROVAL_MODE](key)) { + cycleApprovalMode(); } }, { isActive }, ); - return showApprovalMode; + return { + approvalMode: showApprovalMode, + cycleApprovalMode, + toggleYolo, + }; }