From b14a29efa216bd5b5a38ddb8ea3b4b68725cff42 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Fri, 1 May 2026 12:56:05 -0500 Subject: [PATCH] feat(ui): added wave animation for voice mode (#26284) --- .../src/ui/components/InputPrompt.test.tsx | 51 ++++--------------- .../cli/src/ui/components/InputPrompt.tsx | 15 +++--- .../src/ui/components/ListeningIndicator.tsx | 46 +++++++++++++++++ 3 files changed, 63 insertions(+), 49 deletions(-) create mode 100644 packages/cli/src/ui/components/ListeningIndicator.tsx diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d52897abed..3608f00e3d 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -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 }) => ~~~ ), +})); // Mock ink BEFORE importing components that use it to intercept terminalCursorPosition vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); @@ -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( , { 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( , @@ -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( , { 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( , { 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(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 0e823d77a4..67fefe0656 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ useBackgroundColor={useBackgroundColor} > - {isVoiceModeEnabled && 🎤 } + {isVoiceModeEnabled && + (isRecording ? ( + + ) : ( + 🎤 + ))} = ({ )}{' '} - {isRecording && ( - - Listening... - - )} - {buffer.text.length === 0 && !isRecording ? ( + {buffer.text.length === 0 ? ( effectivePlaceholder ? ( showCursor ? ( = ({ + 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 Listening... ; + } + + // 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 {bars.join('')} ; +};