diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 548372a139..951a38cefc 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -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, }, }, }); diff --git a/packages/cli/src/ui/auth/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx index 03d609c444..4aacd4c08c 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.tsx @@ -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({ ) : ( - Waiting for authentication... (Press Esc - or Ctrl+C to cancel) + Waiting for authentication... (Press Esc or + Ctrl+C to cancel) )} diff --git a/packages/cli/src/ui/components/BrailleAnimation.test.tsx b/packages/cli/src/ui/components/BrailleAnimation.test.tsx new file mode 100644 index 0000000000..3aa5583791 --- /dev/null +++ b/packages/cli/src/ui/components/BrailleAnimation.test.tsx @@ -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( + , + { 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( + , + ); + + // Just verify it renders something + expect(renderResult.lastFrameRaw()).toBeTruthy(); + + act(() => { + renderResult.unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/BrailleAnimation.tsx b/packages/cli/src/ui/components/BrailleAnimation.tsx new file mode 100644 index 0000000000..56597ab075 --- /dev/null +++ b/packages/cli/src/ui/components/BrailleAnimation.tsx @@ -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 = ({ + 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 {getFrame()}; +}; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx index 316438d737..79bd8c4c1d 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -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 ? ( - {SCREEN_READER_LOADING} - ) : ( + if (isScreenReaderEnabled) { + return {SCREEN_READER_LOADING}; + } + + return typeof nonRespondingDisplay === 'string' ? ( {nonRespondingDisplay} + ) : ( + <>{nonRespondingDisplay} ); } diff --git a/packages/cli/src/ui/components/GeminiSpinner.test.tsx b/packages/cli/src/ui/components/GeminiSpinner.test.tsx new file mode 100644 index 0000000000..1268ce2082 --- /dev/null +++ b/packages/cli/src/ui/components/GeminiSpinner.test.tsx @@ -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 }) => ( + BrailleAnimation-{variant} + ), + GEMINI_SPINNER: { interval: 80, frames: [] }, +})); + +vi.mock('./CliSpinner.js', () => ({ + CliSpinner: ({ type }: { type: string }) => CliSpinner-{type}, +})); + +describe('GeminiSpinner', () => { + it('renders BrailleAnimation with "Composite" variant by default', async () => { + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , + ); + 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( + , + ); + 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. + }); +}); diff --git a/packages/cli/src/ui/components/GeminiSpinner.tsx b/packages/cli/src/ui/components/GeminiSpinner.tsx index 37d1930625..4c94144e31 100644 --- a/packages/cli/src/ui/components/GeminiSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiSpinner.tsx @@ -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 = ({ - 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 = ({ }, []); useEffect(() => { - if (isScreenReaderEnabled) { + if (isScreenReaderEnabled || !shouldShow) { return; } @@ -48,16 +52,22 @@ export const GeminiSpinner: React.FC = ({ }, 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 ; + } + + return ; + }; + return isScreenReaderEnabled ? ( {altText} ) : ( - - - + {renderSpinner()} ); }; diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index ef2e21e132..16d6acb1d4 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -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 MockRespondingSpinner; } else if (nonRespondingDisplay) { - return {nonRespondingDisplay}; + return <>{nonRespondingDisplay}; } return null; }, @@ -86,7 +86,7 @@ describe('', () => { ); 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('', () => { }); 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'); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index a48451b26c..9f203e1c06 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -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 = ({ + + + ) : ( + '' + )) } isHookActive={isHookActive} /> @@ -140,9 +145,13 @@ export const LoadingIndicator: React.FC = ({ + + + ) : ( + '' + )) } isHookActive={isHookActive} /> diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx index d5f10cc12c..bb1ab6f99c 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx @@ -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({ {isPending ? ( - + ) : ( )} diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index caed091b2b..36a997ec41 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -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: () => , +vi.mock('../BrailleAnimation.js', () => ({ + BrailleAnimation: () => , })); describe('', () => { diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index 5d1086c759..3d53eaa0a4 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -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' ? ( - + ) : item.status === 'completed' ? ( {TOOL_STATUS.SUCCESS} ) : item.status === 'cancelled' ? ( diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx index d77bf45243..36b9b50378 100644 --- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx +++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx @@ -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 ( - + {state.message} ); @@ -921,7 +921,7 @@ Return a JSON object with: justifyContent="center" height={VISIBLE_CANDIDATES * 2} > - + {state.message} ) : ( diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx index 62c0f50e1c..bbc09f6cba 100644 --- a/packages/cli/src/ui/components/triage/TriageIssues.tsx +++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx @@ -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 ( - + {state.message} ); @@ -521,7 +521,7 @@ Return a JSON object with: if (state.status === 'analyzing') { return ( - + {state.message} ); @@ -610,7 +610,7 @@ Return a JSON object with: > {state.status === 'analyzing' ? ( - + Analyzing issue with Gemini... ) : analysis ? (