Refactor: Add GeminiRespondingSpinner to make use of streamingState idiomatic (#583)
This commit is contained in:
parent
98dcf43214
commit
05a49702d8
|
@ -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 <Spinner type={spinnerType} />;
|
||||||
|
} else if (nonRespondingDisplay) {
|
||||||
|
return <Text>{nonRespondingDisplay}</Text>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -15,9 +15,21 @@ import {
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
// Mock ink-spinner
|
// Mock GeminiRespondingSpinner
|
||||||
vi.mock('ink-spinner', () => ({
|
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||||
default: () => <Text>MockSpinner</Text>,
|
GeminiRespondingSpinner: ({
|
||||||
|
nonRespondingDisplay,
|
||||||
|
}: {
|
||||||
|
nonRespondingDisplay?: string;
|
||||||
|
}) => {
|
||||||
|
const { streamingState } = React.useContext(StreamingContext)!;
|
||||||
|
if (streamingState === StreamingState.Responding) {
|
||||||
|
return <Text>MockRespondingSpinner</Text>;
|
||||||
|
} else if (nonRespondingDisplay) {
|
||||||
|
return <Text>{nonRespondingDisplay}</Text>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderWithContext = (
|
const renderWithContext = (
|
||||||
|
@ -54,12 +66,12 @@ describe('<LoadingIndicator />', () => {
|
||||||
StreamingState.Responding,
|
StreamingState.Responding,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('MockSpinner');
|
expect(output).toContain('MockRespondingSpinner');
|
||||||
expect(output).toContain('Loading...');
|
expect(output).toContain('Loading...');
|
||||||
expect(output).toContain('(esc to cancel, 5s)');
|
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 = {
|
const props = {
|
||||||
currentLoadingPhrase: 'Confirm action',
|
currentLoadingPhrase: 'Confirm action',
|
||||||
elapsedTime: 10,
|
elapsedTime: 10,
|
||||||
|
@ -69,7 +81,7 @@ describe('<LoadingIndicator />', () => {
|
||||||
StreamingState.WaitingForConfirmation,
|
StreamingState.WaitingForConfirmation,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('MockSpinner');
|
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
|
||||||
expect(output).toContain('Confirm action');
|
expect(output).toContain('Confirm action');
|
||||||
expect(output).not.toContain('(esc to cancel)');
|
expect(output).not.toContain('(esc to cancel)');
|
||||||
expect(output).not.toContain(', 10s');
|
expect(output).not.toContain(', 10s');
|
||||||
|
@ -127,7 +139,7 @@ describe('<LoadingIndicator />', () => {
|
||||||
</StreamingContext.Provider>,
|
</StreamingContext.Provider>,
|
||||||
);
|
);
|
||||||
let output = lastFrame();
|
let output = lastFrame();
|
||||||
expect(output).toContain('MockSpinner');
|
expect(output).toContain('MockRespondingSpinner');
|
||||||
expect(output).toContain('Now Responding');
|
expect(output).toContain('Now Responding');
|
||||||
expect(output).toContain('(esc to cancel, 2s)');
|
expect(output).toContain('(esc to cancel, 2s)');
|
||||||
|
|
||||||
|
@ -143,7 +155,7 @@ describe('<LoadingIndicator />', () => {
|
||||||
</StreamingContext.Provider>,
|
</StreamingContext.Provider>,
|
||||||
);
|
);
|
||||||
output = lastFrame();
|
output = lastFrame();
|
||||||
expect(output).not.toContain('MockSpinner');
|
expect(output).toContain('⠏');
|
||||||
expect(output).toContain('Please Confirm');
|
expect(output).toContain('Please Confirm');
|
||||||
expect(output).not.toContain('(esc to cancel)');
|
expect(output).not.toContain('(esc to cancel)');
|
||||||
expect(output).not.toContain(', 15s');
|
expect(output).not.toContain(', 15s');
|
||||||
|
|
|
@ -6,10 +6,10 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import Spinner from 'ink-spinner';
|
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
|
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||||
|
|
||||||
interface LoadingIndicatorProps {
|
interface LoadingIndicatorProps {
|
||||||
currentLoadingPhrase: string;
|
currentLoadingPhrase: string;
|
||||||
|
@ -30,11 +30,13 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box marginTop={1} paddingLeft={0}>
|
<Box marginTop={1} paddingLeft={0}>
|
||||||
{streamingState === StreamingState.Responding && (
|
<Box marginRight={1}>
|
||||||
<Box marginRight={1}>
|
<GeminiRespondingSpinner
|
||||||
<Spinner type="dots" />
|
nonRespondingDisplay={
|
||||||
</Box>
|
streamingState === StreamingState.WaitingForConfirmation ? '⠏' : ''
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Text color={Colors.AccentPurple}>
|
<Text color={Colors.AccentPurple}>
|
||||||
{currentLoadingPhrase}
|
{currentLoadingPhrase}
|
||||||
{streamingState === StreamingState.WaitingForConfirmation
|
{streamingState === StreamingState.WaitingForConfirmation
|
||||||
|
|
|
@ -4,15 +4,29 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import { render } from 'ink-testing-library';
|
import { render } from 'ink-testing-library';
|
||||||
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
|
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
|
||||||
import { StreamingState, ToolCallStatus } from '../../types.js';
|
import { StreamingState, ToolCallStatus } from '../../types.js';
|
||||||
import { Text } from 'ink';
|
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
|
// Mock child components or utilities if they are complex or have side effects
|
||||||
vi.mock('ink-spinner', () => ({
|
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||||
default: () => <Text>MockSpinner</Text>,
|
GeminiRespondingSpinner: ({
|
||||||
|
nonRespondingDisplay,
|
||||||
|
}: {
|
||||||
|
nonRespondingDisplay?: string;
|
||||||
|
}) => {
|
||||||
|
const { streamingState } = React.useContext(StreamingContext)!;
|
||||||
|
if (streamingState === StreamingState.Responding) {
|
||||||
|
return <Text>MockRespondingSpinner</Text>;
|
||||||
|
}
|
||||||
|
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('./DiffRenderer.js', () => ({
|
vi.mock('./DiffRenderer.js', () => ({
|
||||||
DiffRenderer: function MockDiffRenderer({
|
DiffRenderer: function MockDiffRenderer({
|
||||||
|
@ -33,12 +47,14 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||||
const renderWithContext = (
|
const renderWithContext = (
|
||||||
ui: React.ReactElement,
|
ui: React.ReactElement,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
) =>
|
) => {
|
||||||
render(
|
const contextValue: StreamingContextType = { streamingState };
|
||||||
<StreamingContext.Provider value={{ streamingState }}>
|
return render(
|
||||||
|
<StreamingContext.Provider value={contextValue}>
|
||||||
{ui}
|
{ui}
|
||||||
</StreamingContext.Provider>,
|
</StreamingContext.Provider>,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('<ToolMessage />', () => {
|
describe('<ToolMessage />', () => {
|
||||||
const baseProps: ToolMessageProps = {
|
const baseProps: ToolMessageProps = {
|
||||||
|
@ -110,8 +126,8 @@ describe('<ToolMessage />', () => {
|
||||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||||
StreamingState.Idle,
|
StreamingState.Idle,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toContain('⠇');
|
expect(lastFrame()).toContain('⊷');
|
||||||
expect(lastFrame()).not.toContain('MockSpinner');
|
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||||
expect(lastFrame()).not.toContain('✔');
|
expect(lastFrame()).not.toContain('✔');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -120,17 +136,17 @@ describe('<ToolMessage />', () => {
|
||||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||||
StreamingState.WaitingForConfirmation,
|
StreamingState.WaitingForConfirmation,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toContain('⠇');
|
expect(lastFrame()).toContain('⊷');
|
||||||
expect(lastFrame()).not.toContain('MockSpinner');
|
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||||
expect(lastFrame()).not.toContain('✔');
|
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(
|
const { lastFrame } = renderWithContext(
|
||||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||||
StreamingState.Responding, // Simulate app still responding
|
StreamingState.Responding, // Simulate app still responding
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toContain('MockSpinner');
|
expect(lastFrame()).toContain('MockRespondingSpinner');
|
||||||
expect(lastFrame()).not.toContain('✔');
|
expect(lastFrame()).not.toContain('✔');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,16 +6,11 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import Spinner from 'ink-spinner';
|
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||||
import {
|
|
||||||
IndividualToolCallDisplay,
|
|
||||||
StreamingState,
|
|
||||||
ToolCallStatus,
|
|
||||||
} from '../../types.js';
|
|
||||||
import { DiffRenderer } from './DiffRenderer.js';
|
import { DiffRenderer } from './DiffRenderer.js';
|
||||||
import { Colors } from '../../colors.js';
|
import { Colors } from '../../colors.js';
|
||||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||||
import { useStreamingContext } from '../../contexts/StreamingContext.js';
|
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||||
|
|
||||||
const STATIC_HEIGHT = 1;
|
const STATIC_HEIGHT = 1;
|
||||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||||
|
@ -61,7 +56,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||||
return (
|
return (
|
||||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||||
<Box minHeight={1}>
|
<Box minHeight={1}>
|
||||||
{/* Status Indicator */}
|
|
||||||
<ToolStatusIndicator status={status} />
|
<ToolStatusIndicator status={status} />
|
||||||
<ToolInfo
|
<ToolInfo
|
||||||
name={name}
|
name={name}
|
||||||
|
@ -107,41 +101,38 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||||
type ToolStatusIndicatorProps = {
|
type ToolStatusIndicatorProps = {
|
||||||
status: ToolCallStatus;
|
status: ToolCallStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
||||||
status,
|
status,
|
||||||
}) => {
|
}) => (
|
||||||
const { streamingState } = useStreamingContext();
|
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||||
return (
|
{status === ToolCallStatus.Pending && (
|
||||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
<Text color={Colors.AccentGreen}>o</Text>
|
||||||
{status === ToolCallStatus.Pending && (
|
)}
|
||||||
<Text color={Colors.AccentGreen}>o</Text>
|
{status === ToolCallStatus.Executing && (
|
||||||
)}
|
<GeminiRespondingSpinner
|
||||||
{status === ToolCallStatus.Executing &&
|
spinnerType="toggle"
|
||||||
(streamingState === StreamingState.Responding ? (
|
nonRespondingDisplay={'⊷'}
|
||||||
<Spinner type="toggle" />
|
/>
|
||||||
) : (
|
)}
|
||||||
// Paused spinner to avoid flicker.
|
{status === ToolCallStatus.Success && (
|
||||||
<Text>⠇</Text>
|
<Text color={Colors.AccentGreen}>✔</Text>
|
||||||
))}
|
)}
|
||||||
{status === ToolCallStatus.Success && (
|
{status === ToolCallStatus.Confirming && (
|
||||||
<Text color={Colors.AccentGreen}>✔</Text>
|
<Text color={Colors.AccentYellow}>?</Text>
|
||||||
)}
|
)}
|
||||||
{status === ToolCallStatus.Confirming && (
|
{status === ToolCallStatus.Canceled && (
|
||||||
<Text color={Colors.AccentYellow}>?</Text>
|
<Text color={Colors.AccentYellow} bold>
|
||||||
)}
|
-
|
||||||
{status === ToolCallStatus.Canceled && (
|
</Text>
|
||||||
<Text color={Colors.AccentYellow} bold>
|
)}
|
||||||
-
|
{status === ToolCallStatus.Error && (
|
||||||
</Text>
|
<Text color={Colors.AccentRed} bold>
|
||||||
)}
|
x
|
||||||
{status === ToolCallStatus.Error && (
|
</Text>
|
||||||
<Text color={Colors.AccentRed} bold>
|
)}
|
||||||
x
|
</Box>
|
||||||
</Text>
|
);
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolInfo = {
|
type ToolInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
Loading…
Reference in New Issue