mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat(cli): make approval mode instructional tip transient
This commit is contained in:
@@ -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(
|
||||
<ApprovalModeIndicator approvalMode={mode} />,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('accepting edits');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
});
|
||||
|
||||
it('renders correctly for PLAN mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
||||
);
|
||||
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(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||
);
|
||||
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(<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('shift + tab');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing for DEFAULT mode', () => {
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('accepting edits');
|
||||
expect(output).not.toContain('YOLO mode');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApprovalModeIndicatorProps> = ({
|
||||
approvalMode,
|
||||
}) => {
|
||||
const showTip = useTransientTip(approvalMode);
|
||||
let textColor = '';
|
||||
let textContent = '';
|
||||
let subText = '';
|
||||
@@ -45,7 +47,9 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
<Box>
|
||||
<Text color={textColor}>
|
||||
{textContent}
|
||||
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
|
||||
{showTip && subText && (
|
||||
<Text color={theme.text.secondary}>{subText}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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)"`;
|
||||
108
packages/cli/src/ui/hooks/useTransientTip.test.tsx
Normal file
108
packages/cli/src/ui/hooks/useTransientTip.test.tsx
Normal file
@@ -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(
|
||||
<TestComponent val={initialValue} dur={duration} />,
|
||||
);
|
||||
return {
|
||||
result: {
|
||||
get current() {
|
||||
return hookResult;
|
||||
},
|
||||
},
|
||||
rerender: (newProps: { val: unknown; dur?: number }) =>
|
||||
rerender(<TestComponent {...newProps} />),
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
50
packages/cli/src/ui/hooks/useTransientTip.ts
Normal file
50
packages/cli/src/ui/hooks/useTransientTip.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user