mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 12:57:12 -07:00
feat(ui): implement circular 8-dot braille spinner
This commit is contained in:
@@ -22,3 +22,4 @@ Thumbs.db
|
||||
.pytest_cache
|
||||
**/SKILL.md
|
||||
packages/sdk/test-data/*.json
|
||||
**/*.snap
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user