mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-04 00:44:05 -07:00
feat(ui): added wave animation for voice mode (#26284)
This commit is contained in:
@@ -104,7 +104,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('clipboardy');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../hooks/useKittyKeyboardProtocol.js');
|
||||
|
||||
vi.mock('./ListeningIndicator.js', () => ({
|
||||
ListeningIndicator: vi.fn(({ color }) => <Text color={color}>~~~ </Text>),
|
||||
}));
|
||||
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
@@ -4979,7 +4981,6 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
|
||||
// Initially not recording
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain(
|
||||
'Type your message or space to talk (Esc to exit)',
|
||||
@@ -4990,11 +4991,6 @@ describe('InputPrompt', () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Now should show listening
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -5002,7 +4998,7 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
@@ -5016,25 +5012,18 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('some existing text');
|
||||
mockBuffer.setText('First turn.');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
@@ -5048,17 +5037,13 @@ describe('InputPrompt', () => {
|
||||
|
||||
// Should show voice mode prefix even if buffer is not empty
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain('some existing text');
|
||||
expect(lastFrame()).toContain('First turn.');
|
||||
|
||||
// Press space to start recording again
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -5066,7 +5051,7 @@ describe('InputPrompt', () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: false } as UIState,
|
||||
@@ -5082,7 +5067,6 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
// Should NOT show listening, instead should call handleInput which handles space
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -5243,19 +5227,17 @@ describe('InputPrompt', () => {
|
||||
|
||||
// Should insert space optimistically
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
|
||||
// Advance timer past HOLD_DELAY_MS
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should start recording on hold (simulated by repeat spaces)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
@@ -5279,8 +5261,6 @@ describe('InputPrompt', () => {
|
||||
await waitFor(() => {
|
||||
// Should have backspaced the optimistic space
|
||||
expect(mockBuffer.backspace).toHaveBeenCalled();
|
||||
// Should show listening
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
@@ -5303,31 +5283,18 @@ describe('InputPrompt', () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
},
|
||||
{ interval: 10 },
|
||||
);
|
||||
|
||||
// Simulate heartbeat (held key) - send space first to reset timer, then advance
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(lastFrame()).toContain('🎤 >');
|
||||
expect(lastFrame()).toContain('Listening...');
|
||||
expect(lastFrame()).toContain('~~~ >');
|
||||
|
||||
// Stop heartbeat (release)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
import { ListeningIndicator } from './ListeningIndicator.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -1800,7 +1801,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||||
{isVoiceModeEnabled && <Text color={theme.text.accent}>🎤 </Text>}
|
||||
{isVoiceModeEnabled &&
|
||||
(isRecording ? (
|
||||
<ListeningIndicator color={theme.text.accent} />
|
||||
) : (
|
||||
<Text color={theme.text.accent}>🎤 </Text>
|
||||
))}
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
@@ -1825,12 +1831,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.status.success}>Listening...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{buffer.text.length === 0 && !isRecording ? (
|
||||
{buffer.text.length === 0 ? (
|
||||
effectivePlaceholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
|
||||
const WAVE_CHARS = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
const ANIMATION_SPEED = 0.4;
|
||||
const BAR_PHASE_OFFSET = 1.2;
|
||||
const MAX_HEIGHT_MULTIPLIER = 3.5;
|
||||
const FRAME_INTERVAL_MS = 40; // ~33 FPS
|
||||
|
||||
export interface ListeningIndicatorProps {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const ListeningIndicator: React.FC<ListeningIndicatorProps> = ({
|
||||
color,
|
||||
}) => {
|
||||
const [tick, setTick] = useState(0);
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
||||
useEffect(() => {
|
||||
if (isScreenReaderEnabled) return;
|
||||
const timer = setInterval(() => setTick((t) => t + 1), FRAME_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [isScreenReaderEnabled]);
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
return <Text color={color}>Listening... </Text>;
|
||||
}
|
||||
|
||||
// Generate 3 bars for the wave
|
||||
const bars = Array.from({ length: 3 }).map((_, i) => {
|
||||
// Sine wave calculation to map to our 8 block characters (0-7)
|
||||
const phase = tick * ANIMATION_SPEED + i * BAR_PHASE_OFFSET;
|
||||
const height = Math.floor((Math.sin(phase) + 1) * MAX_HEIGHT_MULTIPLIER);
|
||||
return WAVE_CHARS[Math.max(0, Math.min(7, height))] ?? ' ';
|
||||
});
|
||||
|
||||
return <Text color={color}>{bars.join('')} </Text>;
|
||||
};
|
||||
Reference in New Issue
Block a user