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