feat(ui): implement circular 8-dot braille spinner

This commit is contained in:
Keith Guerin
2026-03-27 13:57:21 -07:00
parent ba71ffa736
commit da6bc36deb
13 changed files with 398 additions and 24 deletions
+1
View File
@@ -22,3 +22,4 @@ Thumbs.db
.pytest_cache
**/SKILL.md
packages/sdk/test-data/*.json
**/*.snap
+4
View File
@@ -46,6 +46,7 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -60,6 +61,7 @@ export const createMockSettings = (
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -68,6 +70,8 @@ export const createMockSettings = (
if (mergedOverride) {
// @ts-expect-error - overriding private field for testing
loaded._merged = createTestMergedSettings(mergedOverride);
// @ts-expect-error - re-calculating snapshot after merged override
loaded._snapshot = loaded.computeSnapshot();
}
// Assign any function overrides (e.g., vi.fn() for methods)
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { CircularSpinner } from './CircularSpinner.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
describe('<CircularSpinner />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should render the static frame correctly', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<CircularSpinner variant="Static" />,
);
await waitUntilReady();
expect(lastFrame()?.trim()).toBe('⢎⡱');
});
it('should render Small variant frames (tail length 2)', async () => {
const {
lastFrame: lastFrame0,
waitUntilReady: wait0,
rerender,
} = await renderWithProviders(
<CircularSpinner variant="Small" frameIndex={0} />,
);
await wait0();
// Frame 0: idx 0, 7
// bits1 = 0x08 (⠈), bits2 = 0x01 (⠁)
expect(lastFrame0({ allowEmpty: true })?.trim()).toBe('⠈⠁');
await act(async () => {
rerender(<CircularSpinner variant="Small" frameIndex={1} />);
});
await wait0();
// Frame 1: idx 1, 0
// bits1 = 0, bits2 = 0x11 (⠑)
expect(lastFrame0({ allowEmpty: true })?.trim()).toBe('⠀⠑');
await act(async () => {
rerender(<CircularSpinner variant="Small" frameIndex={2} />);
});
await wait0();
// Frame 2: idx 2, 1
// bits2 = 0x30 (⠰)
expect(lastFrame0({ allowEmpty: true })?.trim()).toBe('⠀⠰');
});
it('should render Medium variant frames (tail length 3)', async () => {
const { lastFrame, waitUntilReady, rerender } = await renderWithProviders(
<CircularSpinner variant="Medium" frameIndex={0} />,
);
await waitUntilReady();
// Frame 0: idx 0, 7, 6
// bits1 = 0x0A (⠊), bits2 = 0x01 (⠁)
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('⠊⠁');
await act(async () => {
rerender(<CircularSpinner variant="Medium" frameIndex={1} />);
});
await waitUntilReady();
// Frame 1: idx 1, 0, 7
// bits1 = 0x08 (⠈), bits2 = 0x11 (⠑)
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('⠈⠑');
});
it('should render Composite variant frames (12 ticks/shift)', async () => {
const { lastFrame, rerender, waitUntilReady } = await renderWithProviders(
<CircularSpinner variant="Composite" frameIndex={0} />,
);
await waitUntilReady();
// index 0: length 2
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('⠈⠁');
await act(async () => {
rerender(<CircularSpinner variant="Composite" frameIndex={11} />);
});
await waitUntilReady();
// index 11: length 2, head shifted 11 mod 8 = 3 (C2.7)
// DOTS[3] = {0, 0x40}, DOTS[2] = {0, 0x20}
// bits2 = 0x40 | 0x20 = 0x60 (⠠)
// Actually the logic is: bits1 |= DOTS[idx].c1; bits2 |= DOTS[idx].c2;
// idx for i=0: (11-0+8)%8 = 3. idx for i=1: (11-1+8)%8 = 2.
// DOTS[3] = {c1:0, c2:0x40}. DOTS[2] = {c1:0, c2:0x20}.
// bits2 = 0x60. char2 = U+2860 (⡠). Wait, 0x2800 + 0x60 = 0x2860.
// Let me check braille chart for 0x60.
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('⠀⡠');
await act(async () => {
rerender(<CircularSpinner variant="Composite" frameIndex={12} />);
});
await waitUntilReady();
// index 12: length 3, head shifted 12 mod 8 = 4 (C1.8)
// DOTS[4] = {0x80, 0}, DOTS[3] = {0, 0x40}, DOTS[2] = {0, 0x20}
// bits1 = 0x80 (⢀), bits2 = 0x40 | 0x20 = 0x60 (⡠)
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('⢀⡠');
});
it('should handle showSpinner setting', async () => {
const settings = createMockSettings({
merged: {
ui: { showSpinner: false },
},
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<CircularSpinner />,
{ settings },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
});
@@ -0,0 +1,141 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, type FC } from 'react';
import { Text } from 'ink';
import { useSettings } from '../contexts/SettingsContext.js';
export type CircularSpinnerVariant =
| 'Small'
| 'Medium'
| 'Long'
| 'Composite'
| 'Static';
interface CircularSpinnerProps {
variant?: CircularSpinnerVariant;
color?: string;
/** Individual color for the first character. Overrides 'color' if provided. */
color1?: string;
/** Individual color for the second character. Overrides 'color' if provided. */
color2?: string;
/** Initial frame index when not controlled. */
startFrameIndex?: number;
/** Directly control the frame index for testing. */
frameIndex?: number;
/** Whether to animate even in test environment. */
animateInTests?: boolean;
}
const TICK_MS = 80;
// Dot bitmasks for a circular perimeter across two characters (c1 and c2).
// Row 1: . C1.4 C2.1 .
// Row 2: C1.2 . . C2.5
// Row 3: C1.3 . . C2.6
// Row 4: . C1.8 C2.7 .
// Clockwise sequence: C2.1 -> C2.5 -> C2.6 -> C2.7 -> C1.8 -> C1.3 -> C1.2 -> C1.4
const DOTS = [
{ c1: 0, c2: 0x01 }, // C2.1
{ c1: 0, c2: 0x10 }, // C2.5
{ c1: 0, c2: 0x20 }, // C2.6
{ c1: 0, c2: 0x40 }, // C2.7
{ c1: 0x80, c2: 0 }, // C1.8
{ c1: 0x04, c2: 0 }, // C1.3
{ c1: 0x02, c2: 0 }, // C1.2
{ c1: 0x08, c2: 0 }, // C1.4
];
export const CircularSpinner: FC<CircularSpinnerProps> = ({
variant = 'Medium',
color,
color1,
color2,
startFrameIndex = 0,
frameIndex: controlledFrameIndex,
animateInTests = false,
}) => {
const settings = useSettings();
const showSpinner = settings.merged.ui?.showSpinner !== false;
const [internalFrameIndex, setInternalFrameIndex] = useState(startFrameIndex);
useEffect(() => {
if (controlledFrameIndex === undefined) {
setInternalFrameIndex(startFrameIndex);
}
}, [startFrameIndex, controlledFrameIndex]);
useEffect(() => {
if (
!showSpinner ||
variant === 'Static' ||
controlledFrameIndex !== undefined ||
(process.env['NODE_ENV'] === 'test' && !animateInTests)
)
return;
const interval = setInterval(() => {
setInternalFrameIndex((prev) => (prev + 1) % 144);
}, TICK_MS);
return () => clearInterval(interval);
}, [showSpinner, variant, controlledFrameIndex, animateInTests]);
if (!showSpinner) return null;
if (variant === 'Static') {
return <Text color={color}></Text>;
}
const effectiveFrameIndex = controlledFrameIndex ?? internalFrameIndex;
const getTailLength = (index: number) => {
switch (variant) {
case 'Small':
return 2;
case 'Medium':
return 3;
case 'Long': {
const lengths = [1, 3, 5];
return lengths[Math.floor(index / 8) % lengths.length];
}
case 'Composite': {
const compositeLengths = [2, 3, 4, 5, 4, 3];
return compositeLengths[
Math.floor(index / 12) % compositeLengths.length
];
}
default:
return 3;
}
};
const tailLength = getTailLength(effectiveFrameIndex);
let bits1 = 0;
let bits2 = 0;
for (let i = 0; i < tailLength; i++) {
const idx = ((effectiveFrameIndex % 8) - i + 8) % 8;
bits1 |= DOTS[idx].c1;
bits2 |= DOTS[idx].c2;
}
const char1 = String.fromCharCode(0x2800 + bits1);
const char2 = String.fromCharCode(0x2800 + bits2);
return (
<Text>
{[
<Text key="c1" color={color1 ?? color}>
{char1}
</Text>,
<Text key="c2" color={color2 ?? color}>
{char2}
</Text>,
]}
</Text>
);
};
@@ -24,11 +24,24 @@ describe('<CliSpinner />', () => {
});
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const settings = createMockSettings({
merged: {
ui: { showSpinner: false },
},
});
const { lastFrame, unmount } = await renderWithProviders(<CliSpinner />, {
settings,
});
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
unmount();
});
it('should render CircularSpinner when useBraille is true', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner useBraille variant="Static" />,
);
await waitUntilReady();
expect(lastFrame()?.trim()).toBe('⢎⡱');
unmount();
});
});
+17 -2
View File
@@ -8,10 +8,21 @@ import Spinner from 'ink-spinner';
import { type ComponentProps, useEffect } from 'react';
import { debugState } from '../debug.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
CircularSpinner,
type CircularSpinnerVariant,
} from './CircularSpinner.js';
export type SpinnerProps = ComponentProps<typeof Spinner>;
type SpinnerProps = ComponentProps<typeof Spinner> & {
useBraille?: boolean;
variant?: CircularSpinnerVariant;
};
export const CliSpinner = (props: SpinnerProps) => {
export const CliSpinner = ({
useBraille = false,
variant = 'Medium',
...props
}: SpinnerProps) => {
const settings = useSettings();
const shouldShow = settings.merged.ui?.showSpinner !== false;
@@ -29,5 +40,9 @@ export const CliSpinner = (props: SpinnerProps) => {
return null;
}
if (useBraille) {
return <CircularSpinner variant={variant} />;
}
return <Spinner {...props} />;
};
@@ -6,7 +6,6 @@
import type React from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import {
@@ -15,6 +14,7 @@ import {
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { GeminiSpinner } from './GeminiSpinner.js';
import { type CircularSpinnerVariant } from './CircularSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
@@ -22,7 +22,7 @@ interface GeminiRespondingSpinnerProps {
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
variant?: CircularSpinnerVariant;
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
@@ -35,7 +35,7 @@ export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({
nonRespondingDisplay,
spinnerType = 'dots',
variant = 'Composite',
isHookActive = false,
color,
}) => {
@@ -46,10 +46,7 @@ export const GeminiRespondingSpinner: React.FC<
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
if (streamingState === StreamingState.Responding && !isHookActive) {
return (
<GeminiSpinner
spinnerType={spinnerType}
altText={SCREEN_READER_RESPONDING}
/>
<GeminiSpinner variant={variant} altText={SCREEN_READER_RESPONDING} />
);
}
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 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, afterEach } from 'vitest';
import { useIsScreenReaderEnabled } from 'ink';
vi.mock('ink', async () => {
const actual = await vi.importActual('ink');
return {
...actual,
useIsScreenReaderEnabled: vi.fn(),
};
});
describe('<GeminiSpinner />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should render CircularSpinner when screen reader is disabled', async () => {
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<GeminiSpinner />);
// Component renders immediately. The interval updates state, but we don't need to wait for it.
expect(lastFrame()).toBeTruthy();
unmount();
});
it('should render altText when screen reader is enabled', async () => {
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(
<GeminiSpinner altText="Custom Loading" />,
);
expect(lastFrame()?.trim()).toBe('Custom Loading');
unmount();
});
});
@@ -7,20 +7,22 @@
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
import {
CircularSpinner,
type CircularSpinnerVariant,
} from './CircularSpinner.js';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiSpinnerProps {
spinnerType?: SpinnerName;
variant?: CircularSpinnerVariant;
altText?: string;
}
export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
spinnerType = 'dots',
variant = 'Composite',
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
@@ -51,13 +53,18 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
}, [isScreenReaderEnabled]);
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();
const leadingColor = googleGradient.rgbAt(progress).toHexString();
// Offset the trailing color by ~10% of the cycle duration
const trailingProgress = (progress - 0.1 + 1) % 1;
const trailingColor = googleGradient.rgbAt(trailingProgress).toHexString();
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
<CircularSpinner
variant={variant}
color1={trailingColor}
color2={leadingColor}
/>
);
};
@@ -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');
@@ -461,7 +461,7 @@ describe('<LoadingIndicator />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('');
expect(output).not.toContain('⢎⡱');
unmount();
});
});
@@ -97,7 +97,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? ''
? '⢎⡱'
: '')
}
isHookActive={isHookActive}
@@ -141,7 +141,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? ''
? '⢎⡱'
: '')
}
isHookActive={isHookActive}
@@ -53,6 +53,10 @@ vi.mock('../contexts/AppContext.js', async () => {
};
});
vi.mock('./GeminiSpinner.js', () => ({
GeminiSpinner: () => null,
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(),
}));
@@ -0,0 +1,29 @@
# Task: Update Braille Loader to Circular Spinner (Clean Re-implementation)
## Objective
Replace the legacy braille animation with a smoother, circular 8-dot spinner
effect spanning two Braille characters.
## Technical Requirements
- **Character Set**: Utilize the Braille Patterns Unicode block (U+2800 -
U+28FF).
- **Dot Mapping**: Map 8 dots in a circular perimeter across two characters (c1
and c2).
- **Variants**:
- `Static`: Fixed frame `⢎⡱` for confirmation states.
- `Small`: Fixed tail length of 2.
- `Medium`: Fixed tail length of 3.
- `Long`: Phased growth (lengths 1, 3, 5).
- `Composite`: Dynamic length sequence `[2, 3, 4, 5, 4, 3]`.
- **Performance**: Use `setInterval` with a default 80ms tick, gated by
`SettingsContext` (showSpinner).
- **Testing**: Update `CircularSpinner.test.tsx` verification frames and ensure
`LoadingIndicator.test.tsx` matches the static `⢎⡱` frame.
## Implementation Steps
1. Define the circular `DOTS` bitmask array.
2. Implement `getFrame()` logic using `String.fromCharCode(0x2800 + bits)`.
3. Verify with `npm test`.