feat(ui): added wave animation for voice mode (#26284)

This commit is contained in:
Dev Randalpura
2026-05-01 12:56:05 -05:00
committed by GitHub
parent f496354884
commit b14a29efa2
3 changed files with 63 additions and 49 deletions
@@ -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>;
};