feat(cli): make approval mode instructional tip transient

This commit is contained in:
Abhi
2026-01-27 00:42:28 -05:00
parent 9fcdc0cdc1
commit 220344ee49
5 changed files with 233 additions and 28 deletions

View File

@@ -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();
});
});

View File

@@ -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>
);

View File

@@ -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)"`;

View 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);
});
});

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