ui(cli): update braille loader to circular spinner

This commit is contained in:
Keith Guerin
2026-03-24 12:38:38 -07:00
parent 36e6445dba
commit ffe1ca8053
14 changed files with 375 additions and 42 deletions
+41 -1
View File
@@ -131,12 +131,43 @@ class MockExtensionManager extends ExtensionLoader {
};
}
// Mock terminalCapabilityManager to avoid terminal setup prompt during tests
vi.mock('../ui/utils/terminalCapabilityManager.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../ui/utils/terminalCapabilityManager.js')
>();
const mockedManager = Object.create(
Object.getPrototypeOf(actual.terminalCapabilityManager),
);
Object.assign(mockedManager, actual.terminalCapabilityManager, {
isKittyProtocolEnabled: () => true,
enableKittyProtocol: vi.fn(),
disableKittyProtocol: vi.fn(),
enableSupportedModes: vi.fn(),
disableSupportedModes: vi.fn(),
onSupportChange: vi.fn(),
offSupportChange: vi.fn(),
});
return {
...actual,
terminalCapabilityManager: mockedManager,
};
});
vi.mock('../ui/components/GeminiSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
};
});
// Mock GeminiRespondingSpinner to disable animations (avoiding 'act()' warnings) without triggering screen reader mode.
vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
const React = await import('react');
const { Text } = await import('ink');
return {
GeminiSpinner: () => React.createElement(Text, null, '...'),
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
@@ -203,6 +234,9 @@ export class AppRig {
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
// Disable the terminal setup prompt globally for AppRig tests.
persistentStateMock.set('terminalSetupPromptShown', true);
const approvalMode =
this.options.configOverrides?.approvalMode ?? ApprovalMode.DEFAULT;
const policyEngineConfig = await createPolicyEngineConfig(
@@ -280,6 +314,10 @@ export class AppRig {
enabled: false,
hasSeenNudge: true,
},
ui: {
hasSeenTerminalSetupPrompt: true,
showSpinner: false,
},
},
originalSettings: {},
},
@@ -299,6 +337,8 @@ export class AppRig {
},
ui: {
useAlternateBuffer: false,
hasSeenTerminalSetupPrompt: true,
showSpinner: false,
},
},
});
+3 -3
View File
@@ -7,7 +7,7 @@
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { CliSpinner } from '../components/CliSpinner.js';
import { BrailleAnimation } from '../components/BrailleAnimation.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -53,8 +53,8 @@ export function AuthInProgress({
) : (
<Box>
<Text>
<CliSpinner type="dots" /> Waiting for authentication... (Press Esc
or Ctrl+C to cancel)
<BrailleAnimation /> Waiting for authentication... (Press Esc or
Ctrl+C to cancel)
</Text>
</Box>
)}
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { BrailleAnimation } from './BrailleAnimation.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
describe('BrailleAnimation', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should grow from length 1 to 5 and match verification frames', async () => {
const settings = createMockSettings({
merged: {
ui: {
showSpinner: true,
},
},
});
// renderWithProviders will call waitUntilReady once.
const renderResult = await renderWithProviders(
<BrailleAnimation interval={100} variant="Long" animate={true} />,
{ settings },
);
const { lastFrameRaw } = renderResult;
const verificationFrames = [
'⢎⠁', // 0
'⠎⠑', // 1
'⠊⠱', // 2
'⠈⡱', // 3
'⢀⡱', // 4
'⢄⡰', // 5
'⢆⡠', // 6
'⢎⡀', // 7
];
// Advance 16 ticks to reach length 5.
for (let i = 0; i < 16; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}
// Now check the sequence.
let current = lastFrameRaw();
let startIdx = verificationFrames.findIndex((f) => current.includes(f));
if (startIdx === -1) {
for (let attempt = 0; attempt < 8; attempt++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
current = lastFrameRaw();
startIdx = verificationFrames.findIndex((f) => current.includes(f));
if (startIdx !== -1) break;
}
}
expect(
startIdx,
`Should have reached length 5 frames. Current: ${current}`,
).not.toBe(-1);
// Verify the sequence.
for (let i = 0; i < 8; i++) {
const idx = (startIdx + i) % 8;
expect(lastFrameRaw()).toContain(verificationFrames[idx]);
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}
act(() => {
renderResult.unmount();
});
});
it('should support "Composite" variant with dynamic lengths', async () => {
const renderResult = await renderWithProviders(
<BrailleAnimation interval={100} variant="Composite" animate={true} />,
);
// Just verify it renders something
expect(renderResult.lastFrameRaw()).toBeTruthy();
act(() => {
renderResult.unmount();
});
});
});
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Text } from 'ink';
import { debugState } from '../debug.js';
import { useSettings } from '../contexts/SettingsContext.js';
// Dot bitmasks and character assignments for the 4x4 circle perimeter
// Char 0 corresponds to the first Braille character (c1), Char 1 to the second (c2).
const DOTS = [
{ char: 1, bit: 1 }, // Dot 1 (c2)
{ char: 1, bit: 16 }, // Dot 5 (c2)
{ char: 1, bit: 32 }, // Dot 6 (c2)
{ char: 1, bit: 64 }, // Dot 7 (c2)
{ char: 0, bit: 128 }, // Dot 8 (c1)
{ char: 0, bit: 4 }, // Dot 3 (c1)
{ char: 0, bit: 2 }, // Dot 2 (c1)
{ char: 0, bit: 8 }, // Dot 4 (c1)
];
const COMPOSITE_SEQUENCE = [2, 3, 4, 5, 4, 3];
export type BrailleVariant =
| 'Static'
| 'Small'
| 'Medium'
| 'Long'
| 'Composite';
interface BrailleAnimationProps {
variant?: BrailleVariant;
interval?: number;
animate?: boolean;
}
/**
* Braille Snake Animation Component
*
* Variants match the prototype style:
* - 'Static': Fixed frame '⢎⡱'
* - 'Small': Fixed length 2
* - 'Medium': Fixed length 3
* - 'Long': Phased growth (len 1, 3, 5) changing every 8 ticks
* - 'Composite': Dynamic length [2, 3, 4, 5, 4, 3] changing every 8 ticks
*/
export const BrailleAnimation: React.FC<BrailleAnimationProps> = ({
variant = 'Composite',
interval = 80,
animate = !process.env['VITEST'],
}) => {
const [tick, setTick] = useState(0);
const settings = useSettings();
const shouldShow = settings.merged.ui?.showSpinner !== false;
useEffect(() => {
if (!shouldShow || !animate) return;
debugState.debugNumAnimatedComponents++;
const timer = setInterval(() => {
setTick((t) => t + 1);
}, interval);
return () => {
debugState.debugNumAnimatedComponents--;
clearInterval(timer);
};
}, [interval, shouldShow, animate]);
const getLength = () => {
const cycle = Math.floor(tick / 8);
switch (variant) {
case 'Small':
return 2;
case 'Medium':
return 3;
case 'Long':
return cycle === 0 ? 1 : cycle === 1 ? 3 : 5;
case 'Composite':
return COMPOSITE_SEQUENCE[cycle % COMPOSITE_SEQUENCE.length];
case 'Static':
return 0;
default:
return 5;
}
};
const getFrame = () => {
if (variant === 'Static') {
return '⢎⡱';
}
const length = getLength();
let [c1, c2] = [0, 0];
const head = tick % 8;
for (let i = 0; i < length; i++) {
const { char, bit } = DOTS[(head - i + 80) % 8];
char === 0 ? (c1 |= bit) : (c2 |= bit);
}
return String.fromCharCode(0x2800 + c1) + String.fromCharCode(0x2800 + c2);
};
if (!shouldShow) {
return null;
}
return <Text>{getFrame()}</Text>;
};
@@ -15,14 +15,13 @@ import {
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { GeminiSpinner } from './GeminiSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string to display when not in Responding state.
* Optional string or component to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
nonRespondingDisplay?: React.ReactNode;
spinnerType?: SpinnerName | 'dynamic';
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
@@ -35,7 +34,7 @@ export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({
nonRespondingDisplay,
spinnerType = 'dots',
spinnerType = 'dynamic',
isHookActive = false,
color,
}) => {
@@ -54,10 +53,14 @@ export const GeminiRespondingSpinner: React.FC<
}
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
if (isScreenReaderEnabled) {
return <Text>{SCREEN_READER_LOADING}</Text>;
}
return typeof nonRespondingDisplay === 'string' ? (
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
) : (
<>{nonRespondingDisplay}</>
);
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { GeminiSpinner } from './GeminiSpinner.js';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { act } from 'react';
// Mock components to simplify testing
vi.mock('./BrailleAnimation.js', () => ({
BrailleAnimation: ({ variant }: { variant: string }) => (
<Text>BrailleAnimation-{variant}</Text>
),
GEMINI_SPINNER: { interval: 80, frames: [] },
}));
vi.mock('./CliSpinner.js', () => ({
CliSpinner: ({ type }: { type: string }) => <Text>CliSpinner-{type}</Text>,
}));
describe('GeminiSpinner', () => {
it('renders BrailleAnimation with "Composite" variant by default', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<GeminiSpinner />,
);
await waitUntilReady();
expect(lastFrame()).toContain('BrailleAnimation-Composite');
act(() => {
unmount();
});
});
it('renders CliSpinner when a specific spinnerType string is provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<GeminiSpinner spinnerType="dots" />,
);
await waitUntilReady();
expect(lastFrame()).toContain('CliSpinner-dots');
act(() => {
unmount();
});
});
it('renders screen reader text when screen reader is enabled', async () => {
// Note: useIsScreenReaderEnabled is used in GeminiSpinner
// We would need to mock it if we wanted to test this explicitly,
// but the default is false in our test environment.
});
});
@@ -11,19 +11,23 @@ import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
import { BrailleAnimation } from './BrailleAnimation.js';
import { useSettings } from '../contexts/SettingsContext.js';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiSpinnerProps {
spinnerType?: SpinnerName;
spinnerType?: SpinnerName | 'dynamic';
altText?: string;
}
export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
spinnerType = 'dots',
spinnerType = 'dynamic',
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const settings = useSettings();
const shouldShow = settings.merged.ui?.showSpinner !== false;
const [time, setTime] = useState(0);
const googleGradient = useMemo(() => {
@@ -39,7 +43,7 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
}, []);
useEffect(() => {
if (isScreenReaderEnabled) {
if (isScreenReaderEnabled || !shouldShow) {
return;
}
@@ -48,16 +52,22 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
}, 30); // ~33fps for smooth color transitions
return () => clearInterval(interval);
}, [isScreenReaderEnabled]);
}, [isScreenReaderEnabled, shouldShow]);
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();
const renderSpinner = () => {
if (spinnerType === 'dynamic') {
return <BrailleAnimation variant="Composite" />;
}
return <CliSpinner type={spinnerType} />;
};
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
<Text color={currentColor}>{renderSpinner()}</Text>
);
};
@@ -18,13 +18,13 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
nonRespondingDisplay?: React.ReactNode;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
return <>{nonRespondingDisplay}</>;
}
return null;
},
@@ -86,7 +86,7 @@ describe('<LoadingIndicator />', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(''); // Static char for WaitingForConfirmation
expect(output).toContain('⢎⡱'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
@@ -208,7 +208,7 @@ describe('<LoadingIndicator />', () => {
});
await waitUntilReady();
output = lastFrame();
expect(output).toContain('');
expect(output).toContain('⢎⡱');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
@@ -11,6 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { BrailleAnimation } from './BrailleAnimation.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
@@ -96,9 +97,13 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
(streamingState === StreamingState.WaitingForConfirmation ? (
<Text color={theme.text.primary}>
<BrailleAnimation variant="Static" />
</Text>
) : (
''
))
}
isHookActive={isHookActive}
/>
@@ -140,9 +145,13 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
(streamingState === StreamingState.WaitingForConfirmation ? (
<Text color={theme.text.primary}>
<BrailleAnimation variant="Static" />
</Text>
) : (
''
))
}
isHookActive={isHookActive}
/>
@@ -6,7 +6,7 @@
import { Box, Text } from 'ink';
import type { CompressionProps } from '../../types.js';
import { CliSpinner } from '../CliSpinner.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { CompressionStatus } from '@google/gemini-cli-core';
@@ -61,7 +61,7 @@ export function CompressionMessage({
<Box flexDirection="row">
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
<BrailleAnimation />
) : (
<Text color={theme.text.accent}></Text>
)}
@@ -10,8 +10,8 @@ import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Text } from 'ink';
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
vi.mock('../BrailleAnimation.js', () => ({
BrailleAnimation: () => <Text></Text>,
}));
describe('<SubagentProgressDisplay />', () => {
@@ -7,8 +7,8 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import Spinner from 'ink-spinner';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
import type {
SubagentProgress,
SubagentActivityItem,
@@ -106,7 +106,7 @@ export const SubagentProgressDisplay: React.FC<
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === 'running' ? (
<Spinner type="dots" />
<BrailleAnimation />
) : item.status === 'completed' ? (
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
) : item.status === 'cancelled' ? (
@@ -6,7 +6,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import {
debugLogger,
spawnAsync,
@@ -16,6 +15,7 @@ import {
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
interface Issue {
number: number;
@@ -725,7 +725,7 @@ Return a JSON object with:
if (state.status === 'loading') {
return (
<Box>
<Spinner type="dots" />
<BrailleAnimation />
<Text> {state.message}</Text>
</Box>
);
@@ -921,7 +921,7 @@ Return a JSON object with:
justifyContent="center"
height={VISIBLE_CANDIDATES * 2}
>
<Spinner type="dots" />
<BrailleAnimation />
<Text> {state.message}</Text>
</Box>
) : (
@@ -6,7 +6,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import {
debugLogger,
spawnAsync,
@@ -18,6 +17,7 @@ import { Command } from '../../key/keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { BrailleAnimation } from '../BrailleAnimation.js';
interface Issue {
number: number;
@@ -448,7 +448,7 @@ Return a JSON object with:
if (state.status === 'loading') {
return (
<Box>
<Spinner type="dots" />
<BrailleAnimation />
<Text> {state.message}</Text>
</Box>
);
@@ -521,7 +521,7 @@ Return a JSON object with:
if (state.status === 'analyzing') {
return (
<Box>
<Spinner type="dots" />
<BrailleAnimation />
<Text> {state.message}</Text>
</Box>
);
@@ -610,7 +610,7 @@ Return a JSON object with:
>
{state.status === 'analyzing' ? (
<Box>
<Spinner type="dots" />
<BrailleAnimation />
<Text> Analyzing issue with Gemini...</Text>
</Box>
) : analysis ? (