mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bfd4781b5 | |||
| 13facb6d05 | |||
| b35081759f | |||
| 783f5b9461 | |||
| 275232b1be |
@@ -34,7 +34,7 @@ available combinations.
|
||||
| ------------------------------------------------ | --------------------------------------------------------- |
|
||||
| Delete from the cursor to the end of the line. | `Ctrl + K` |
|
||||
| Delete from the cursor to the start of the line. | `Ctrl + U` |
|
||||
| Clear all text in the input field. | `Ctrl + C` |
|
||||
| Clear all text in the input field. | `Esc` |
|
||||
| Delete the previous word. | `Ctrl + Backspace`<br />`Cmd + Backspace`<br />`Ctrl + W` |
|
||||
| Delete the next word. | `Ctrl + Delete`<br />`Cmd + Delete` |
|
||||
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
|
||||
@@ -117,7 +117,8 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Browse and rewind previous interactions.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
otherwise browse and rewind previous interactions.
|
||||
- `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
|
||||
@@ -143,7 +143,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
|
||||
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
|
||||
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.CLEAR_INPUT]: [{ key: 'escape' }],
|
||||
// Added command (meta/alt/option) for mac compatibility
|
||||
[Command.DELETE_WORD_BACKWARD]: [
|
||||
{ key: 'backspace', ctrl: true },
|
||||
|
||||
@@ -12,6 +12,17 @@ import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: {
|
||||
get: vi.fn().mockImplementation((key) => {
|
||||
if (key === 'tipsShown') return 0;
|
||||
if (key === 'defaultBannerShownCount') return {};
|
||||
return undefined;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
getTerminalProgram: () => null,
|
||||
}));
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('../utils/terminalSetup.js', () => ({
|
||||
describe('<AppHeader />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
persistentStateMock.get.mockReturnValue({});
|
||||
persistentStateMock.get.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('should render the banner with default text', () => {
|
||||
@@ -134,6 +134,7 @@ describe('<AppHeader />', () => {
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
persistentStateMock.get.mockReturnValue(5);
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
@@ -200,4 +201,81 @@ describe('<AppHeader />', () => {
|
||||
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render Tips when tipsShown is less than 10', () => {
|
||||
persistentStateMock.get.mockImplementation((key) => {
|
||||
if (key === 'tipsShown') return 5;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig, uiState },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Tips');
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT render Tips when tipsShown is 10 or more', () => {
|
||||
persistentStateMock.get.mockImplementation((key) => {
|
||||
if (key === 'tipsShown') return 10;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{ config: mockConfig },
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Tips');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show tips until they have been shown 10 times (persistence flow)', () => {
|
||||
const fakeStore: Record<string, number> = {
|
||||
tipsShown: 9,
|
||||
};
|
||||
|
||||
persistentStateMock.get.mockImplementation((key) => fakeStore[key]);
|
||||
persistentStateMock.set.mockImplementation((key, val) => {
|
||||
fakeStore[key] = val;
|
||||
});
|
||||
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'First line\\nSecond line',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
const session1 = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
});
|
||||
|
||||
expect(session1.lastFrame()).toContain('Tips');
|
||||
expect(fakeStore['tipsShown']).toBe(10);
|
||||
session1.unmount();
|
||||
|
||||
const session2 = renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||
config: mockConfig,
|
||||
});
|
||||
|
||||
expect(session2.lastFrame()).not.toContain('Tips');
|
||||
session2.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { useBanner } from '../hooks/useBanner.js';
|
||||
import { useTips } from '../hooks/useTips.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
@@ -23,6 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const tipsShown = useTips();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
@@ -38,9 +40,8 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
)}
|
||||
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
|
||||
!tipsShown && <Tips config={config} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1253,25 +1253,6 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer on Ctrl+C if it has text', async () => {
|
||||
await act(async () => {
|
||||
props.buffer.setText('some text to clear');
|
||||
});
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
uiActions,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x03'); // Ctrl+C character
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
});
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
|
||||
props.buffer.text = '';
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
@@ -1874,7 +1855,7 @@ describe('InputPrompt', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('should clear buffer on Ctrl-C', async () => {
|
||||
it('should NOT clear buffer on Ctrl-C', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('text to clear');
|
||||
@@ -1887,16 +1868,16 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\x03');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit /rewind on double ESC', async () => {
|
||||
it('should submit /rewind on double ESC when buffer is empty', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('some text');
|
||||
props.buffer.setText('');
|
||||
vi.mocked(props.buffer.setText).mockClear();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
@@ -1911,6 +1892,26 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer on esc esc if it has text', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
props.buffer.setText('some text');
|
||||
vi.mocked(props.buffer.setText).mockClear();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1B\x1B');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset escape state on any non-ESC key', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
props.onEscapePromptChange = onEscapePromptChange;
|
||||
|
||||
@@ -495,7 +495,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle double ESC for rewind
|
||||
// Handle double ESC
|
||||
if (escPressCount.current === 0) {
|
||||
escPressCount.current = 1;
|
||||
setShowEscapePrompt(true);
|
||||
@@ -506,9 +506,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
} else {
|
||||
// Second ESC triggers rewind
|
||||
// Second ESC
|
||||
resetEscapeState();
|
||||
onSubmit('/rewind');
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key) && buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
} else {
|
||||
onSubmit('/rewind');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -790,15 +795,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
|
||||
@@ -12,10 +12,17 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { act } from 'react';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js');
|
||||
vi.mock('../contexts/UIStateContext.js');
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('ink', async () => {
|
||||
const actual = await vi.importActual('ink');
|
||||
return {
|
||||
@@ -30,6 +37,7 @@ vi.mock('node:fs/promises', async () => {
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
vi.mock('node:os', () => ({
|
||||
@@ -68,7 +76,9 @@ describe('Notifications', () => {
|
||||
const mockUseUIState = vi.mocked(useUIState);
|
||||
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
||||
const mockFsAccess = vi.mocked(fs.access);
|
||||
const mockFsWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockFsUnlink = vi.mocked(fs.unlink);
|
||||
const mockPersistentStateGet = vi.mocked(persistentState.get);
|
||||
const mockPersistentStateSet = vi.mocked(persistentState.set);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -82,6 +92,7 @@ describe('Notifications', () => {
|
||||
updateInfo: null,
|
||||
} as unknown as UIState);
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(false);
|
||||
mockPersistentStateGet.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('renders nothing when no notifications', () => {
|
||||
@@ -134,51 +145,45 @@ describe('Notifications', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders screen reader nudge when enabled and not seen', async () => {
|
||||
it('renders screen reader nudge when enabled and not seen (no legacy file)', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
|
||||
let rejectAccess: (err: Error) => void;
|
||||
mockFsAccess.mockImplementation(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
rejectAccess = reject;
|
||||
}),
|
||||
);
|
||||
mockPersistentStateGet.mockReturnValue(false);
|
||||
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
|
||||
|
||||
const { lastFrame } = render(<Notifications />);
|
||||
|
||||
// Trigger rejection inside act
|
||||
await act(async () => {
|
||||
rejectAccess(new Error('File not found'));
|
||||
});
|
||||
|
||||
// Wait for effect to propagate
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFsWriteFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('screen reader-friendly view');
|
||||
expect(mockPersistentStateSet).toHaveBeenCalledWith(
|
||||
'hasSeenScreenReaderNudge',
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render screen reader nudge when already seen', async () => {
|
||||
it('migrates legacy screen reader nudge file', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
mockPersistentStateGet.mockReturnValue(undefined);
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
let resolveAccess: (val: undefined) => void;
|
||||
mockFsAccess.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveAccess = resolve;
|
||||
}),
|
||||
);
|
||||
render(<Notifications />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(mockPersistentStateSet).toHaveBeenCalledWith(
|
||||
'hasSeenScreenReaderNudge',
|
||||
true,
|
||||
);
|
||||
expect(mockFsUnlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render screen reader nudge when already seen in persistent state', async () => {
|
||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||
mockPersistentStateGet.mockReturnValue(true);
|
||||
|
||||
const { lastFrame } = render(<Notifications />);
|
||||
|
||||
// Trigger resolution inside act
|
||||
await act(async () => {
|
||||
resolveAccess(undefined);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(mockFsWriteFile).not.toHaveBeenCalled();
|
||||
expect(mockPersistentStateSet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,9 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
debugLogger,
|
||||
homedir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core';
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -38,15 +34,20 @@ export const Notifications = () => {
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
|
||||
persistentState.get('hasSeenScreenReaderNudge'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenReader = async () => {
|
||||
const checkLegacyScreenReaderNudge = async () => {
|
||||
if (hasSeenScreenReaderNudge !== undefined) return;
|
||||
|
||||
try {
|
||||
await fs.access(screenReaderNudgeFilePath);
|
||||
persistentState.set('hasSeenScreenReaderNudge', true);
|
||||
setHasSeenScreenReaderNudge(true);
|
||||
// Best effort cleanup of legacy file
|
||||
await fs.unlink(screenReaderNudgeFilePath).catch(() => {});
|
||||
} catch {
|
||||
setHasSeenScreenReaderNudge(false);
|
||||
}
|
||||
@@ -54,28 +55,17 @@ export const Notifications = () => {
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
checkScreenReader();
|
||||
checkLegacyScreenReaderNudge();
|
||||
}
|
||||
}, [isScreenReaderEnabled]);
|
||||
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
|
||||
|
||||
const showScreenReaderNudge =
|
||||
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
|
||||
|
||||
useEffect(() => {
|
||||
const writeScreenReaderNudgeFile = async () => {
|
||||
if (showScreenReaderNudge) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(screenReaderNudgeFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(screenReaderNudgeFilePath, 'true');
|
||||
} catch (error) {
|
||||
debugLogger.error('Error storing screen reader nudge', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
writeScreenReaderNudgeFile();
|
||||
if (showScreenReaderNudge) {
|
||||
persistentState.set('hasSeenScreenReaderNudge', true);
|
||||
}
|
||||
}, [showScreenReaderNudge]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -45,7 +45,12 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
}
|
||||
|
||||
if (uiState.showEscapePrompt) {
|
||||
return <Text color={theme.text.secondary}>Press Esc again to rewind.</Text>;
|
||||
const isPromptEmpty = uiState.buffer.text.trim().length === 0;
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.queueErrorMessage) {
|
||||
|
||||
@@ -7,12 +7,6 @@ exports[`Notifications > renders init error 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Notifications > renders screen reader nudge when enabled and not seen 1`] = `
|
||||
"You are currently in screen reader-friendly view. To switch out, open
|
||||
/mock/home/.gemini/settings.json and remove the entry for "screenReader". This will disappear on
|
||||
next run."
|
||||
`;
|
||||
|
||||
exports[`Notifications > renders update notification 1`] = `
|
||||
"
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useTips } from './useTips.js';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
|
||||
vi.mock('../../utils/persistentState.js', () => ({
|
||||
persistentState: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useTips()', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return false and call set(1) if state is undefined', () => {
|
||||
vi.mocked(persistentState.get).mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHookWithProviders(() => useTips());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
expect(persistentState.set).toHaveBeenCalledWith('tipsShown', 1);
|
||||
});
|
||||
|
||||
it('should return false and call set(6) if state is 5', () => {
|
||||
vi.mocked(persistentState.get).mockReturnValue(5);
|
||||
|
||||
const { result } = renderHookWithProviders(() => useTips());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
expect(persistentState.set).toHaveBeenCalledWith('tipsShown', 6);
|
||||
});
|
||||
|
||||
it('should return true if state is 10', () => {
|
||||
vi.mocked(persistentState.get).mockReturnValue(10);
|
||||
|
||||
const { result } = renderHookWithProviders(() => useTips());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(persistentState.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { persistentState } from '../../utils/persistentState.js';
|
||||
|
||||
export function useTips() {
|
||||
const [tipsCount] = useState(() => persistentState.get('tipsShown') ?? 0);
|
||||
|
||||
const tipsHidden = tipsCount >= 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!tipsHidden) {
|
||||
persistentState.set('tipsShown', tipsCount + 1);
|
||||
}
|
||||
}, [tipsCount, tipsHidden]);
|
||||
|
||||
return tipsHidden;
|
||||
}
|
||||
@@ -96,8 +96,12 @@ describe('keyMatchers', () => {
|
||||
},
|
||||
{
|
||||
command: Command.CLEAR_INPUT,
|
||||
positive: [createKey('c', { ctrl: true })],
|
||||
negative: [createKey('c'), createKey('k', { ctrl: true })],
|
||||
positive: [createKey('escape')],
|
||||
negative: [
|
||||
createKey('c', { ctrl: true }),
|
||||
createKey('c'),
|
||||
createKey('k', { ctrl: true }),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.DELETE_CHAR_LEFT,
|
||||
|
||||
@@ -12,6 +12,8 @@ const STATE_FILENAME = 'state.json';
|
||||
|
||||
interface PersistentStateData {
|
||||
defaultBannerShownCount?: Record<string, number>;
|
||||
tipsShown?: number;
|
||||
hasSeenScreenReaderNudge?: boolean;
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user