diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx new file mode 100644 index 00000000..57f5dd30 --- /dev/null +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type { SpinnerName } from 'cli-spinners'; +import { useStreamingContext } from '../contexts/StreamingContext.js'; +import { StreamingState } from '../types.js'; + +interface GeminiRespondingSpinnerProps { + /** + * Optional string to display when not in Responding state. + * If not provided and not Responding, renders null. + */ + nonRespondingDisplay?: string; + spinnerType?: SpinnerName; +} + +export const GeminiRespondingSpinner: React.FC< + GeminiRespondingSpinnerProps +> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => { + const { streamingState } = useStreamingContext(); + + if (streamingState === StreamingState.Responding) { + return ; + } else if (nonRespondingDisplay) { + return {nonRespondingDisplay}; + } + return null; +}; diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index a03fb230..c74003e4 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -15,9 +15,21 @@ import { import { StreamingState } from '../types.js'; import { vi } from 'vitest'; -// Mock ink-spinner -vi.mock('ink-spinner', () => ({ - default: () => MockSpinner, +// Mock GeminiRespondingSpinner +vi.mock('./GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: ({ + nonRespondingDisplay, + }: { + nonRespondingDisplay?: string; + }) => { + const { streamingState } = React.useContext(StreamingContext)!; + if (streamingState === StreamingState.Responding) { + return MockRespondingSpinner; + } else if (nonRespondingDisplay) { + return {nonRespondingDisplay}; + } + return null; + }, })); const renderWithContext = ( @@ -54,12 +66,12 @@ describe('', () => { StreamingState.Responding, ); const output = lastFrame(); - expect(output).toContain('MockSpinner'); + expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Loading...'); expect(output).toContain('(esc to cancel, 5s)'); }); - it('should render phrase and time but no spinner when streamingState is WaitingForConfirmation', () => { + it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => { const props = { currentLoadingPhrase: 'Confirm action', elapsedTime: 10, @@ -69,7 +81,7 @@ describe('', () => { StreamingState.WaitingForConfirmation, ); const output = lastFrame(); - expect(output).not.toContain('MockSpinner'); + expect(output).toContain('⠏'); // Static char for WaitingForConfirmation expect(output).toContain('Confirm action'); expect(output).not.toContain('(esc to cancel)'); expect(output).not.toContain(', 10s'); @@ -127,7 +139,7 @@ describe('', () => { , ); let output = lastFrame(); - expect(output).toContain('MockSpinner'); + expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Now Responding'); expect(output).toContain('(esc to cancel, 2s)'); @@ -143,7 +155,7 @@ describe('', () => { , ); output = lastFrame(); - expect(output).not.toContain('MockSpinner'); + expect(output).toContain('⠏'); expect(output).toContain('Please Confirm'); expect(output).not.toContain('(esc to cancel)'); expect(output).not.toContain(', 15s'); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index d24b6a56..c3865f3e 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -6,10 +6,10 @@ import React from 'react'; import { Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; import { Colors } from '../colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; +import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; interface LoadingIndicatorProps { currentLoadingPhrase: string; @@ -30,11 +30,13 @@ export const LoadingIndicator: React.FC = ({ return ( - {streamingState === StreamingState.Responding && ( - - - - )} + + + {currentLoadingPhrase} {streamingState === StreamingState.WaitingForConfirmation diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 10380ad4..a40ca31b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -4,15 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { render } from 'ink-testing-library'; import { ToolMessage, ToolMessageProps } from './ToolMessage.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; import { Text } from 'ink'; -import { StreamingContext } from '../../contexts/StreamingContext.js'; +import { + StreamingContext, + StreamingContextType, +} from '../../contexts/StreamingContext.js'; // Mock child components or utilities if they are complex or have side effects -vi.mock('ink-spinner', () => ({ - default: () => MockSpinner, +vi.mock('../GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: ({ + nonRespondingDisplay, + }: { + nonRespondingDisplay?: string; + }) => { + const { streamingState } = React.useContext(StreamingContext)!; + if (streamingState === StreamingState.Responding) { + return MockRespondingSpinner; + } + return nonRespondingDisplay ? {nonRespondingDisplay} : null; + }, })); vi.mock('./DiffRenderer.js', () => ({ DiffRenderer: function MockDiffRenderer({ @@ -33,12 +47,14 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ const renderWithContext = ( ui: React.ReactElement, streamingState: StreamingState, -) => - render( - +) => { + const contextValue: StreamingContextType = { streamingState }; + return render( + {ui} , ); +}; describe('', () => { const baseProps: ToolMessageProps = { @@ -110,8 +126,8 @@ describe('', () => { , StreamingState.Idle, ); - expect(lastFrame()).toContain('⠇'); - expect(lastFrame()).not.toContain('MockSpinner'); + expect(lastFrame()).toContain('⊷'); + expect(lastFrame()).not.toContain('MockRespondingSpinner'); expect(lastFrame()).not.toContain('✔'); }); @@ -120,17 +136,17 @@ describe('', () => { , StreamingState.WaitingForConfirmation, ); - expect(lastFrame()).toContain('⠇'); - expect(lastFrame()).not.toContain('MockSpinner'); + expect(lastFrame()).toContain('⊷'); + expect(lastFrame()).not.toContain('MockRespondingSpinner'); expect(lastFrame()).not.toContain('✔'); }); - it('shows MockSpinner for Executing status when streamingState is Responding', () => { + it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => { const { lastFrame } = renderWithContext( , StreamingState.Responding, // Simulate app still responding ); - expect(lastFrame()).toContain('MockSpinner'); + expect(lastFrame()).toContain('MockRespondingSpinner'); expect(lastFrame()).not.toContain('✔'); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index c8b61297..922f59d0 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -6,16 +6,11 @@ import React from 'react'; import { Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; -import { - IndividualToolCallDisplay, - StreamingState, - ToolCallStatus, -} from '../../types.js'; +import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { useStreamingContext } from '../../contexts/StreamingContext.js'; +import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -61,7 +56,6 @@ export const ToolMessage: React.FC = ({ return ( - {/* Status Indicator */} = ({ type ToolStatusIndicatorProps = { status: ToolCallStatus; }; + const ToolStatusIndicator: React.FC = ({ status, -}) => { - const { streamingState } = useStreamingContext(); - return ( - - {status === ToolCallStatus.Pending && ( - o - )} - {status === ToolCallStatus.Executing && - (streamingState === StreamingState.Responding ? ( - - ) : ( - // Paused spinner to avoid flicker. - - ))} - {status === ToolCallStatus.Success && ( - - )} - {status === ToolCallStatus.Confirming && ( - ? - )} - {status === ToolCallStatus.Canceled && ( - - - - - )} - {status === ToolCallStatus.Error && ( - - x - - )} - - ); -}; +}) => ( + + {status === ToolCallStatus.Pending && ( + o + )} + {status === ToolCallStatus.Executing && ( + + )} + {status === ToolCallStatus.Success && ( + + )} + {status === ToolCallStatus.Confirming && ( + ? + )} + {status === ToolCallStatus.Canceled && ( + + - + + )} + {status === ToolCallStatus.Error && ( + + x + + )} + +); type ToolInfo = { name: string;