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