diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index a5ddf5ac34..b56fe03166 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -4,45 +4,71 @@ * 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, beforeEach, afterEach } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; +import { act } from 'react'; describe('ApprovalModeIndicator', () => { - it('renders correctly for AUTO_EDIT mode', () => { - const { lastFrame } = render( + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const modes = [ + { mode: ApprovalMode.AUTO_EDIT, name: 'AUTO_EDIT' }, + { mode: ApprovalMode.PLAN, name: 'PLAN' }, + { mode: ApprovalMode.YOLO, name: 'YOLO' }, + ]; + + it.each(modes)( + 'renders correctly for $name mode and hides tip after timeout', + ({ mode }) => { + const { lastFrame } = renderWithProviders( + , + ); + + // Initial render with tip + expect(lastFrame()).toMatchSnapshot(); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + // Render after timeout (tip should be gone) + expect(lastFrame()).toMatchSnapshot(); + }, + ); + + it('reshows tip when mode changes', () => { + const { lastFrame, rerender } = renderWithProviders( , ); - const output = lastFrame(); - expect(output).toContain('accepting edits'); - 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)'); - }); + expect(lastFrame()).toContain('shift + tab'); - it('renders correctly for YOLO mode', () => { - const { lastFrame } = render( - , - ); - const output = lastFrame(); - expect(output).toContain('YOLO mode'); - expect(output).toContain('(ctrl + y to toggle)'); + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(lastFrame()).not.toContain('shift + tab'); + + act(() => { + rerender(); + }); + + expect(lastFrame()).toContain('shift + tab'); + expect(lastFrame()).toMatchSnapshot(); }); it('renders nothing for DEFAULT mode', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); - expect(output).not.toContain('accepting edits'); - expect(output).not.toContain('YOLO mode'); + expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx index 875cb0d84b..c3d61ceb91 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@google/gemini-cli-core'; +import { useTransientTip } from '../hooks/useTransientTip.js'; interface ApprovalModeIndicatorProps { approvalMode: ApprovalMode; @@ -16,6 +17,7 @@ interface ApprovalModeIndicatorProps { export const ApprovalModeIndicator: React.FC = ({ approvalMode, }) => { + const showTip = useTransientTip(approvalMode); let textColor = ''; let textContent = ''; let subText = ''; @@ -45,7 +47,9 @@ export const ApprovalModeIndicator: React.FC = ({ {textContent} - {subText && {subText}} + {showTip && subText && ( + {subText} + )} ); diff --git a/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap new file mode 100644 index 0000000000..85c87819ac --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ApprovalModeIndicator.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ApprovalModeIndicator > renders correctly for 'AUTO_EDIT' mode and hides tip after timeout 1`] = `"accepting edits (shift + tab to cycle)"`; + +exports[`ApprovalModeIndicator > renders correctly for 'AUTO_EDIT' mode and hides tip after timeout 2`] = `"accepting edits"`; + +exports[`ApprovalModeIndicator > renders correctly for 'PLAN' mode and hides tip after timeout 1`] = `"plan mode (shift + tab to cycle)"`; + +exports[`ApprovalModeIndicator > renders correctly for 'PLAN' mode and hides tip after timeout 2`] = `"plan mode"`; + +exports[`ApprovalModeIndicator > renders correctly for 'YOLO' mode and hides tip after timeout 1`] = `"YOLO mode (ctrl + y to toggle)"`; + +exports[`ApprovalModeIndicator > renders correctly for 'YOLO' mode and hides tip after timeout 2`] = `"YOLO mode"`; + +exports[`ApprovalModeIndicator > renders nothing for DEFAULT mode 1`] = `""`; + +exports[`ApprovalModeIndicator > reshows tip when mode changes 1`] = `"plan mode (shift + tab to cycle)"`; diff --git a/packages/cli/src/ui/hooks/useTransientTip.test.tsx b/packages/cli/src/ui/hooks/useTransientTip.test.tsx new file mode 100644 index 0000000000..88cda8a773 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTransientTip.test.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { render } from '../../test-utils/render.js'; +import { useTransientTip } from './useTransientTip.js'; + +describe('useTransientTip', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const renderTransientTipHook = (initialValue: unknown, duration?: number) => { + let hookResult: boolean; + function TestComponent({ val, dur }: { val: unknown; dur?: number }) { + hookResult = useTransientTip(val, dur); + return null; + } + const { rerender } = render( + , + ); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: { val: unknown; dur?: number }) => + rerender(), + }; + }; + + it('should return true initially when triggerValue is provided', () => { + const { result } = renderTransientTipHook('test'); + expect(result.current).toBe(true); + }); + + it('should return false after the duration', () => { + const { result } = renderTransientTipHook('test', 1000); + expect(result.current).toBe(true); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current).toBe(false); + }); + + it('should reset the timer when triggerValue changes', () => { + const { result, rerender } = renderTransientTipHook('test1', 1000); + + expect(result.current).toBe(true); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe(true); + + // Rerender with new value + act(() => { + rerender({ val: 'test2', dur: 1000 }); + }); + expect(result.current).toBe(true); + + act(() => { + vi.advanceTimersByTime(600); + }); + + // It should still be true because the 1000ms timer was reset at 500ms + expect(result.current).toBe(true); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(result.current).toBe(false); + }); + + it('should return false if triggerValue is null or undefined', () => { + const { result, rerender } = renderTransientTipHook('test'); + + expect(result.current).toBe(true); + + act(() => { + rerender({ val: null }); + }); + expect(result.current).toBe(false); + + act(() => { + rerender({ val: 'test2' }); + }); + expect(result.current).toBe(true); + + act(() => { + rerender({ val: undefined }); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTransientTip.ts b/packages/cli/src/ui/hooks/useTransientTip.ts new file mode 100644 index 0000000000..cf7001ac29 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTransientTip.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; + +/** + * A hook that returns true for a limited duration whenever a trigger value changes. + * + * @param triggerValue Value that triggers tip visibility when changed. + * @param duration Duration in milliseconds for tip visibility. + */ +export function useTransientTip( + triggerValue: unknown, + duration = 5000, +): boolean { + const [state, setState] = useState({ + value: triggerValue, + show: triggerValue !== undefined && triggerValue !== null, + }); + + // Synchronously update state during render to avoid flickering in the TUI. + if (triggerValue !== state.value) { + setState({ + value: triggerValue, + show: triggerValue !== undefined && triggerValue !== null, + }); + } + + useEffect(() => { + if (triggerValue === undefined || triggerValue === null) { + return; + } + + const timer = setTimeout(() => { + setState((s) => ({ ...s, show: false })); + }, duration); + + return () => clearTimeout(timer); + }, [triggerValue, duration]); + + // Return the intended value for the current render pass. + if (triggerValue !== state.value) { + return triggerValue !== undefined && triggerValue !== null; + } + + return state.show; +}