diff options
17 files changed, 1190 insertions, 739 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json index 5a7c6522..b7976fd6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -82,6 +82,46 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["<node_internals>/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug useLoadingIndicator Test (CLI)", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test", + "-w", + "packages/cli", + "--", + "--inspect-brk=9229", + "--no-file-parallelism", + "${workspaceFolder}/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["<node_internals>/**"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug LoadingIndicator Test (CLI)", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test", + "-w", + "packages/cli", + "--", + "--inspect-brk=9229", + "--no-file-parallelism", + "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["<node_internals>/**"] } ] } diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 1453487f..5860e36e 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -41,6 +41,10 @@ import { useHistory } from './hooks/useHistoryManager.js'; import process from 'node:process'; import { getErrorMessage, type Config } from '@gemini-code/server'; import { useLogger } from './hooks/useLogger.js'; +import { + StreamingContext, + StreamingContextType, +} from './contexts/StreamingContext.js'; interface AppProps { config: Config; @@ -174,19 +178,15 @@ export const App = ({ handleSlashCommand, shellModeActive, ); - const isPausedForConfirmation = useMemo( - () => - pendingHistoryItems.some( - (item) => - item?.type === 'tool_group' && - item.tools.some((tool) => tool.status === 'Confirming'), - ), - [pendingHistoryItems], - ); - const { elapsedTime, currentLoadingPhrase, shouldShowSpinner } = - useLoadingIndicator(streamingState, isPausedForConfirmation); + const { elapsedTime, currentLoadingPhrase } = + useLoadingIndicator(streamingState); const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); + const streamingContextValue: StreamingContextType = useMemo( + () => ({ streamingState }), + [streamingState], + ); + const handleFinalSubmit = useCallback( (submittedValue: string) => { const trimmedValue = submittedValue.trim(); @@ -278,177 +278,176 @@ export const App = ({ }, [consoleMessages, config]); return ( - <Box flexDirection="column" marginBottom={1} width="90%"> - {/* - * The Static component is an Ink intrinsic in which there can only be 1 per application. - * Because of this restriction we're hacking it slightly by having a 'header' item here to - * ensure that it's statically rendered. - * - * Background on the Static Item: Anything in the Static component is written a single time - * to the console. Think of it like doing a console.log and then never using ANSI codes to - * clear that content ever again. Effectively it has a moving frame that every time new static - * content is set it'll flush content to the terminal and move the area which it's "clearing" - * down a notch. Without Static the area which gets erased and redrawn continuously grows. - */} - <Static - key={staticKey} - items={[ - <Box flexDirection="column" key="header"> - <Header /> - <Tips /> - </Box>, - ...history.map((h) => ( + <StreamingContext.Provider value={streamingContextValue}> + <Box flexDirection="column" marginBottom={1} width="90%"> + {/* + * The Static component is an Ink intrinsic in which there can only be 1 per application. + * Because of this restriction we're hacking it slightly by having a 'header' item here to + * ensure that it's statically rendered. + * + * Background on the Static Item: Anything in the Static component is written a single time + * to the console. Think of it like doing a console.log and then never using ANSI codes to + * clear that content ever again. Effectively it has a moving frame that every time new static + * content is set it'll flush content to the terminal and move the area which it's "clearing" + * down a notch. Without Static the area which gets erased and redrawn continuously grows. + */} + <Static + key={staticKey} + items={[ + <Box flexDirection="column" key="header"> + <Header /> + <Tips /> + </Box>, + ...history.map((h) => ( + <HistoryItemDisplay + availableTerminalHeight={availableTerminalHeight} + key={h.id} + item={h} + isPending={false} + /> + )), + ]} + > + {(item) => item} + </Static> + <Box ref={pendingHistoryItemRef}> + {pendingHistoryItems.map((item, i) => ( <HistoryItemDisplay + key={i} availableTerminalHeight={availableTerminalHeight} - key={h.id} - item={h} - isPending={false} - streamingState={streamingState} + // TODO(taehykim): It seems like references to ids aren't necessary in + // HistoryItemDisplay. Refactor later. Use a fake id for now. + item={{ ...item, id: 0 }} + isPending={true} /> - )), - ]} - > - {(item) => item} - </Static> - <Box ref={pendingHistoryItemRef}> - {pendingHistoryItems.map((item, i) => ( - <HistoryItemDisplay - key={i} - availableTerminalHeight={availableTerminalHeight} - // TODO(taehykim): It seems like references to ids aren't necessary in - // HistoryItemDisplay. Refactor later. Use a fake id for now. - item={{ ...item, id: 0 }} - isPending={true} - streamingState={streamingState} - /> - ))} - </Box> - {showHelp && <Help commands={slashCommands} />} - - <Box flexDirection="column" ref={mainControlsRef}> - {startupWarnings.length > 0 && ( - <Box - borderStyle="round" - borderColor={Colors.AccentYellow} - paddingX={1} - marginY={1} - flexDirection="column" - > - {startupWarnings.map((warning, index) => ( - <Text key={index} color={Colors.AccentYellow}> - {warning} - </Text> - ))} - </Box> - )} + ))} + </Box> + {showHelp && <Help commands={slashCommands} />} - {isThemeDialogOpen ? ( - <Box flexDirection="column"> - {themeError && ( - <Box marginBottom={1}> - <Text color={Colors.AccentRed}>{themeError}</Text> - </Box> - )} - <ThemeDialog - onSelect={handleThemeSelect} - onHighlight={handleThemeHighlight} - settings={settings} - /> - </Box> - ) : ( - <> - <LoadingIndicator - isLoading={streamingState === StreamingState.Responding} - showSpinner={shouldShowSpinner} - currentLoadingPhrase={currentLoadingPhrase} - elapsedTime={elapsedTime} - /> + <Box flexDirection="column" ref={mainControlsRef}> + {startupWarnings.length > 0 && ( <Box - marginTop={1} - display="flex" - justifyContent="space-between" - width="100%" + borderStyle="round" + borderColor={Colors.AccentYellow} + paddingX={1} + marginY={1} + flexDirection="column" > - <Box> - {process.env.GEMINI_SYSTEM_MD && ( - <Text color={Colors.AccentRed}>|⌐■_■| </Text> - )} - {geminiMdFileCount > 0 && ( - <Text color={Colors.SubtleComment}> - Using {geminiMdFileCount} GEMINI.md file - {geminiMdFileCount > 1 ? 's' : ''} - </Text> - )} - </Box> - <Box> - {showAutoAcceptIndicator && !shellModeActive && ( - <AutoAcceptIndicator /> - )} - {shellModeActive && <ShellModeIndicator />} - </Box> + {startupWarnings.map((warning, index) => ( + <Text key={index} color={Colors.AccentYellow}> + {warning} + </Text> + ))} </Box> + )} - {showErrorDetails && ( - <DetailedMessagesDisplay messages={filteredConsoleMessages} /> - )} - - {isInputActive && ( - <InputPrompt - widthFraction={0.9} - onSubmit={handleFinalSubmit} - userMessages={userMessages} - onClearScreen={handleClearScreen} - config={config} - slashCommands={slashCommands} - shellModeActive={shellModeActive} - setShellModeActive={setShellModeActive} + {isThemeDialogOpen ? ( + <Box flexDirection="column"> + {themeError && ( + <Box marginBottom={1}> + <Text color={Colors.AccentRed}>{themeError}</Text> + </Box> + )} + <ThemeDialog + onSelect={handleThemeSelect} + onHighlight={handleThemeHighlight} + settings={settings} + /> + </Box> + ) : ( + <> + <LoadingIndicator + currentLoadingPhrase={currentLoadingPhrase} + elapsedTime={elapsedTime} /> - )} - </> - )} + <Box + marginTop={1} + display="flex" + justifyContent="space-between" + width="100%" + > + <Box> + {process.env.GEMINI_SYSTEM_MD && ( + <Text color={Colors.AccentRed}>|⌐■_■| </Text> + )} + {geminiMdFileCount > 0 && ( + <Text color={Colors.SubtleComment}> + Using {geminiMdFileCount} GEMINI.md file + {geminiMdFileCount > 1 ? 's' : ''} + </Text> + )} + </Box> + <Box> + {showAutoAcceptIndicator && !shellModeActive && ( + <AutoAcceptIndicator /> + )} + {shellModeActive && <ShellModeIndicator />} + </Box> + </Box> - {initError && streamingState !== StreamingState.Responding && ( - <Box - borderStyle="round" - borderColor={Colors.AccentRed} - paddingX={1} - marginBottom={1} - > - {history.find( - (item) => item.type === 'error' && item.text?.includes(initError), - )?.text ? ( - <Text color={Colors.AccentRed}> - { - history.find( - (item) => - item.type === 'error' && item.text?.includes(initError), - )?.text - } - </Text> - ) : ( - <> - <Text color={Colors.AccentRed}> - Initialization Error: {initError} - </Text> + {showErrorDetails && ( + <DetailedMessagesDisplay messages={filteredConsoleMessages} /> + )} + + {isInputActive && ( + <InputPrompt + widthFraction={0.9} + onSubmit={handleFinalSubmit} + userMessages={userMessages} + onClearScreen={handleClearScreen} + config={config} + slashCommands={slashCommands} + shellModeActive={shellModeActive} + setShellModeActive={setShellModeActive} + /> + )} + </> + )} + + {initError && streamingState !== StreamingState.Responding && ( + <Box + borderStyle="round" + borderColor={Colors.AccentRed} + paddingX={1} + marginBottom={1} + > + {history.find( + (item) => + item.type === 'error' && item.text?.includes(initError), + )?.text ? ( <Text color={Colors.AccentRed}> - {' '} - Please check API key and configuration. + { + history.find( + (item) => + item.type === 'error' && item.text?.includes(initError), + )?.text + } </Text> - </> - )} - </Box> - )} + ) : ( + <> + <Text color={Colors.AccentRed}> + Initialization Error: {initError} + </Text> + <Text color={Colors.AccentRed}> + {' '} + Please check API key and configuration. + </Text> + </> + )} + </Box> + )} - <Footer - config={config} - debugMode={config.getDebugMode()} - debugMessage={debugMessage} - cliVersion={cliVersion} - corgiMode={corgiMode} - errorCount={errorCount} - showErrorDetails={showErrorDetails} - /> + <Footer + config={config} + debugMode={config.getDebugMode()} + debugMessage={debugMessage} + cliVersion={cliVersion} + corgiMode={corgiMode} + errorCount={errorCount} + showErrorDetails={showErrorDetails} + /> + </Box> </Box> - </Box> + </StreamingContext.Provider> ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 21ffb5a1..39cc5308 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import type { HistoryItem, StreamingState } from '../types.js'; +import type { HistoryItem } from '../types.js'; import { UserMessage } from './messages/UserMessage.js'; import { UserShellMessage } from './messages/UserShellMessage.js'; import { GeminiMessage } from './messages/GeminiMessage.js'; @@ -20,14 +20,12 @@ interface HistoryItemDisplayProps { item: HistoryItem; availableTerminalHeight: number; isPending: boolean; - streamingState?: StreamingState; } export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ item, availableTerminalHeight, isPending, - streamingState, }) => ( <Box flexDirection="column" key={item.id}> {/* Render standard message types */} @@ -62,7 +60,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ toolCalls={item.tools} groupId={item.id} availableTerminalHeight={availableTerminalHeight} - streamingState={streamingState} /> )} </Box> diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 6a8880d4..a03fb230 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -4,91 +4,158 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; import { render } from 'ink-testing-library'; -import { Text } from 'ink'; // Import Text directly from ink +import { Text } from 'ink'; import { LoadingIndicator } from './LoadingIndicator.js'; +import { + StreamingContext, + StreamingContextType, +} from '../contexts/StreamingContext.js'; +import { StreamingState } from '../types.js'; +import { vi } from 'vitest'; +// Mock ink-spinner vi.mock('ink-spinner', () => ({ - default: function MockSpinner() { - return <Text>MockSpinner</Text>; - }, + default: () => <Text>MockSpinner</Text>, })); +const renderWithContext = ( + ui: React.ReactElement, + streamingStateValue: StreamingState, +) => { + const contextValue: StreamingContextType = { + streamingState: streamingStateValue, + }; + return render( + <StreamingContext.Provider value={contextValue}> + {ui} + </StreamingContext.Provider>, + ); +}; + describe('<LoadingIndicator />', () => { - it('should not render when isLoading is false', () => { - const { lastFrame } = render( - <LoadingIndicator - isLoading={false} - showSpinner={true} - currentLoadingPhrase="Loading..." - elapsedTime={0} - />, + const defaultProps = { + currentLoadingPhrase: 'Loading...', + elapsedTime: 5, + }; + + it('should not render when streamingState is Idle', () => { + const { lastFrame } = renderWithContext( + <LoadingIndicator {...defaultProps} />, + StreamingState.Idle, ); expect(lastFrame()).toBe(''); }); - it('should render spinner, phrase, and time when isLoading is true and showSpinner is true', () => { - const phrase = 'Processing data...'; - const time = 5; - const { lastFrame } = render( - <LoadingIndicator - isLoading={true} - showSpinner={true} - currentLoadingPhrase={phrase} - elapsedTime={time} - />, + it('should render spinner, phrase, and time when streamingState is Responding', () => { + const { lastFrame } = renderWithContext( + <LoadingIndicator {...defaultProps} />, + StreamingState.Responding, ); - const output = lastFrame(); - expect(output).toContain(phrase); - expect(output).toContain(`(esc to cancel, ${time}s)`); - // Check for spinner presence by looking for its characteristic characters or structure - // This is a bit fragile as it depends on Spinner's output. - // A more robust way would be to mock Spinner and check if it was rendered. - expect(output).toContain('MockSpinner'); // Check for the mocked spinner text + expect(output).toContain('MockSpinner'); + expect(output).toContain('Loading...'); + expect(output).toContain('(esc to cancel, 5s)'); }); - it('should render phrase and time but no spinner when isLoading is true and showSpinner is false', () => { - const phrase = 'Waiting for input...'; - const time = 10; - const { lastFrame } = render( - <LoadingIndicator - isLoading={true} - showSpinner={false} - currentLoadingPhrase={phrase} - elapsedTime={time} - />, + it('should render phrase and time but no spinner when streamingState is WaitingForConfirmation', () => { + const props = { + currentLoadingPhrase: 'Confirm action', + elapsedTime: 10, + }; + const { lastFrame } = renderWithContext( + <LoadingIndicator {...props} />, + StreamingState.WaitingForConfirmation, ); const output = lastFrame(); - expect(output).toContain(phrase); - expect(output).toContain(`(esc to cancel, ${time}s)`); - // Ensure spinner characters are NOT present expect(output).not.toContain('MockSpinner'); + expect(output).toContain('Confirm action'); + expect(output).not.toContain('(esc to cancel)'); + expect(output).not.toContain(', 10s'); }); it('should display the currentLoadingPhrase correctly', () => { - const specificPhrase = 'Almost there!'; - const { lastFrame } = render( - <LoadingIndicator - isLoading={true} - showSpinner={true} - currentLoadingPhrase={specificPhrase} - elapsedTime={3} - />, + const props = { + currentLoadingPhrase: 'Processing data...', + elapsedTime: 3, + }; + const { lastFrame } = renderWithContext( + <LoadingIndicator {...props} />, + StreamingState.Responding, + ); + expect(lastFrame()).toContain('Processing data...'); + }); + + it('should display the elapsedTime correctly when Responding', () => { + const props = { + currentLoadingPhrase: 'Working...', + elapsedTime: 8, + }; + const { lastFrame } = renderWithContext( + <LoadingIndicator {...props} />, + StreamingState.Responding, + ); + expect(lastFrame()).toContain('(esc to cancel, 8s)'); + }); + + it('should render rightContent when provided', () => { + const rightContent = <Text>Extra Info</Text>; + const { lastFrame } = renderWithContext( + <LoadingIndicator {...defaultProps} rightContent={rightContent} />, + StreamingState.Responding, ); - expect(lastFrame()).toContain(specificPhrase); + expect(lastFrame()).toContain('Extra Info'); }); - it('should display the elapsedTime correctly', () => { - const specificTime = 7; - const { lastFrame } = render( - <LoadingIndicator - isLoading={true} - showSpinner={true} - currentLoadingPhrase="Working..." - elapsedTime={specificTime} - />, + it('should transition correctly between states using rerender', () => { + const { lastFrame, rerender } = renderWithContext( + <LoadingIndicator {...defaultProps} />, + StreamingState.Idle, ); - expect(lastFrame()).toContain(`(esc to cancel, ${specificTime}s)`); + expect(lastFrame()).toBe(''); // Initial: Idle + + // Transition to Responding + rerender( + <StreamingContext.Provider + value={{ streamingState: StreamingState.Responding }} + > + <LoadingIndicator + currentLoadingPhrase="Now Responding" + elapsedTime={2} + /> + </StreamingContext.Provider>, + ); + let output = lastFrame(); + expect(output).toContain('MockSpinner'); + expect(output).toContain('Now Responding'); + expect(output).toContain('(esc to cancel, 2s)'); + + // Transition to WaitingForConfirmation + rerender( + <StreamingContext.Provider + value={{ streamingState: StreamingState.WaitingForConfirmation }} + > + <LoadingIndicator + currentLoadingPhrase="Please Confirm" + elapsedTime={15} + /> + </StreamingContext.Provider>, + ); + output = lastFrame(); + expect(output).not.toContain('MockSpinner'); + expect(output).toContain('Please Confirm'); + expect(output).not.toContain('(esc to cancel)'); + expect(output).not.toContain(', 15s'); + + // Transition back to Idle + rerender( + <StreamingContext.Provider + value={{ streamingState: StreamingState.Idle }} + > + <LoadingIndicator {...defaultProps} /> + </StreamingContext.Provider>, + ); + expect(lastFrame()).toBe(''); }); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index b0c24f80..d24b6a56 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -8,36 +8,38 @@ import React from 'react'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import { Colors } from '../colors.js'; +import { useStreamingContext } from '../contexts/StreamingContext.js'; +import { StreamingState } from '../types.js'; interface LoadingIndicatorProps { - isLoading: boolean; - showSpinner: boolean; currentLoadingPhrase: string; elapsedTime: number; rightContent?: React.ReactNode; } export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ - isLoading, - showSpinner, currentLoadingPhrase, elapsedTime, rightContent, }) => { - if (!isLoading) { + const { streamingState } = useStreamingContext(); + + if (streamingState === StreamingState.Idle) { return null; } return ( <Box marginTop={1} paddingLeft={0}> - {showSpinner && ( + {streamingState === StreamingState.Responding && ( <Box marginRight={1}> <Spinner type="dots" /> </Box> )} <Text color={Colors.AccentPurple}> {currentLoadingPhrase} - {isLoading && ` (esc to cancel, ${elapsedTime}s)`} + {streamingState === StreamingState.WaitingForConfirmation + ? '' + : ` (esc to cancel, ${elapsedTime}s)`} </Text> <Box flexGrow={1}>{/* Spacer */}</Box> {rightContent && <Box>{rightContent}</Box>} diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index c6c8b874..8bcde3bb 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -6,11 +6,7 @@ import React, { useMemo } from 'react'; import { Box } from 'ink'; -import { - IndividualToolCallDisplay, - StreamingState, - ToolCallStatus, -} from '../../types.js'; +import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { Colors } from '../../colors.js'; @@ -19,14 +15,12 @@ interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; availableTerminalHeight: number; - streamingState?: StreamingState; } // Main component renders the border and maps the tools using ToolMessage export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ toolCalls, availableTerminalHeight, - streamingState, }) => { const hasPending = !toolCalls.every( (t) => t.status === ToolCallStatus.Success, @@ -78,7 +72,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({ ? 'low' : 'medium' } - streamingState={streamingState} /> </Box> {tool.status === ToolCallStatus.Confirming && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 1c81cc36..10380ad4 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -8,6 +8,7 @@ import { render } from 'ink-testing-library'; import { ToolMessage, ToolMessageProps } from './ToolMessage.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; import { Text } from 'ink'; +import { StreamingContext } from '../../contexts/StreamingContext.js'; // Mock child components or utilities if they are complex or have side effects vi.mock('ink-spinner', () => ({ @@ -28,6 +29,17 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ }, })); +// Helper to render with context +const renderWithContext = ( + ui: React.ReactElement, + streamingState: StreamingState, +) => + render( + <StreamingContext.Provider value={{ streamingState }}> + {ui} + </StreamingContext.Provider>, + ); + describe('<ToolMessage />', () => { const baseProps: ToolMessageProps = { callId: 'tool-123', @@ -38,11 +50,13 @@ describe('<ToolMessage />', () => { availableTerminalHeight: 20, confirmationDetails: undefined, emphasis: 'medium', - streamingState: StreamingState.Idle, }; it('renders basic tool information', () => { - const { lastFrame } = render(<ToolMessage {...baseProps} />); + const { lastFrame } = renderWithContext( + <ToolMessage {...baseProps} />, + StreamingState.Idle, + ); const output = lastFrame(); expect(output).toContain('✔'); // Success indicator expect(output).toContain('test-tool'); @@ -52,73 +66,72 @@ describe('<ToolMessage />', () => { describe('ToolStatusIndicator rendering', () => { it('shows ✔ for Success status', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Success} />, + StreamingState.Idle, ); expect(lastFrame()).toContain('✔'); }); it('shows o for Pending status', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Pending} />, + StreamingState.Idle, ); expect(lastFrame()).toContain('o'); }); it('shows ? for Confirming status', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Confirming} />, + StreamingState.Idle, ); expect(lastFrame()).toContain('?'); }); it('shows - for Canceled status', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Canceled} />, + StreamingState.Idle, ); expect(lastFrame()).toContain('-'); }); it('shows x for Error status', () => { - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Error} />, + StreamingState.Idle, ); expect(lastFrame()).toContain('x'); }); - it('shows MockSpinner for Executing status when streamingState is Idle', () => { - const { lastFrame } = render( - <ToolMessage - {...baseProps} - status={ToolCallStatus.Executing} - streamingState={StreamingState.Idle} - />, + it('shows paused spiner for Executing status when streamingState is Idle', () => { + const { lastFrame } = renderWithContext( + <ToolMessage {...baseProps} status={ToolCallStatus.Executing} />, + StreamingState.Idle, ); - expect(lastFrame()).toContain('MockSpinner'); + expect(lastFrame()).toContain('⠇'); + expect(lastFrame()).not.toContain('MockSpinner'); expect(lastFrame()).not.toContain('✔'); }); - it('shows MockSpinner for Executing status when streamingState is undefined (default behavior)', () => { - const { lastFrame } = render( + it('shows paused spiner for Executing status when streamingState is WaitingForConfirmation', () => { + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} status={ToolCallStatus.Executing} />, + StreamingState.WaitingForConfirmation, ); - expect(lastFrame()).toContain('MockSpinner'); + expect(lastFrame()).toContain('⠇'); + expect(lastFrame()).not.toContain('MockSpinner'); expect(lastFrame()).not.toContain('✔'); }); - it('shows ✔ (paused/confirmed look) for Executing status when streamingState is Responding', () => { - // This is the key change from the commit: if the overall app is still responding - // (e.g., waiting for other tool confirmations), an already confirmed and executing tool - // should show a static checkmark to avoid spinner flicker. - const { lastFrame } = render( - <ToolMessage - {...baseProps} - status={ToolCallStatus.Executing} - streamingState={StreamingState.Responding} // Simulate app still responding - />, + it('shows MockSpinner for Executing status when streamingState is Responding', () => { + const { lastFrame } = renderWithContext( + <ToolMessage {...baseProps} status={ToolCallStatus.Executing} />, + StreamingState.Responding, // Simulate app still responding ); - expect(lastFrame()).toContain('✔'); // Should be a checkmark, not spinner - expect(lastFrame()).not.toContain('MockSpinner'); + expect(lastFrame()).toContain('MockSpinner'); + expect(lastFrame()).not.toContain('✔'); }); }); @@ -127,22 +140,25 @@ describe('<ToolMessage />', () => { fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new', fileName: 'file.txt', }; - const { lastFrame } = render( + const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} resultDisplay={diffResult} />, + StreamingState.Idle, ); // Check that the output contains the MockDiff content as part of the whole message expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/); }); it('renders emphasis correctly', () => { - const { lastFrame: highEmphasisFrame } = render( + const { lastFrame: highEmphasisFrame } = renderWithContext( <ToolMessage {...baseProps} emphasis="high" />, + StreamingState.Idle, ); // Check for trailing indicator or specific color if applicable (Colors are not easily testable here) expect(highEmphasisFrame()).toContain('←'); // Trailing indicator for high emphasis - const { lastFrame: lowEmphasisFrame } = render( + const { lastFrame: lowEmphasisFrame } = renderWithContext( <ToolMessage {...baseProps} emphasis="low" />, + StreamingState.Idle, ); // For low emphasis, the name and description might be dimmed (check for dimColor if possible) // This is harder to assert directly in text output without color checks. diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 49743190..775b4177 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -15,6 +15,7 @@ import { import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { useStreamingContext } from '../../contexts/StreamingContext.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -25,7 +26,6 @@ export type TextEmphasis = 'high' | 'medium' | 'low'; export interface ToolMessageProps extends IndividualToolCallDisplay { availableTerminalHeight: number; emphasis?: TextEmphasis; - streamingState?: StreamingState; } export const ToolMessage: React.FC<ToolMessageProps> = ({ @@ -35,7 +35,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ status, availableTerminalHeight, emphasis = 'medium', - streamingState, }) => { const contentHeightEstimate = availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT; @@ -63,7 +62,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ <Box paddingX={1} paddingY={0} flexDirection="column"> <Box minHeight={1}> {/* Status Indicator */} - <ToolStatusIndicator status={status} streamingState={streamingState} /> + <ToolStatusIndicator status={status} /> <ToolInfo name={name} status={status} @@ -107,48 +106,42 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ type ToolStatusIndicatorProps = { status: ToolCallStatus; - streamingState?: StreamingState; }; const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({ status, - streamingState, -}) => ( - <Box minWidth={STATUS_INDICATOR_WIDTH}> - {status === ToolCallStatus.Pending && ( - <Text color={Colors.AccentGreen}>o</Text> - )} - {status === ToolCallStatus.Executing && - (streamingState === StreamingState.Responding ? ( - // If the tool is responding that means the user has already confirmed - // this tool call, so we can show a checkmark. The call won't complete - // executing until all confirmations are done. Showing a spinner would - // be misleading as the task is not actually executing at the moment - // and also has flickering issues due to Ink rendering limitations. - // If this hack becomes a problem, we can always add an additional prop - // indicating that the tool was indeed confirmed. If the tool was not - // confirmed we could show a paused version of the spinner. - <Text color={Colors.Gray}>✔</Text> - ) : ( - <Spinner type="dots" /> - ))} - {status === ToolCallStatus.Success && ( - <Text color={Colors.AccentGreen}>✔</Text> - )} - {status === ToolCallStatus.Confirming && ( - <Text color={Colors.AccentYellow}>?</Text> - )} - {status === ToolCallStatus.Canceled && ( - <Text color={Colors.AccentYellow} bold> - - - </Text> - )} - {status === ToolCallStatus.Error && ( - <Text color={Colors.AccentRed} bold> - x - </Text> - )} - </Box> -); +}) => { + const { streamingState } = useStreamingContext(); + return ( + <Box minWidth={STATUS_INDICATOR_WIDTH}> + {status === ToolCallStatus.Pending && ( + <Text color={Colors.AccentGreen}>o</Text> + )} + {status === ToolCallStatus.Executing && + (streamingState === StreamingState.Responding ? ( + <Spinner type="dots" /> + ) : ( + // Paused spinner to avoid flicker. + <Text>⠇</Text> + ))} + {status === ToolCallStatus.Success && ( + <Text color={Colors.AccentGreen}>✔</Text> + )} + {status === ToolCallStatus.Confirming && ( + <Text color={Colors.AccentYellow}>?</Text> + )} + {status === ToolCallStatus.Canceled && ( + <Text color={Colors.AccentYellow} bold> + - + </Text> + )} + {status === ToolCallStatus.Error && ( + <Text color={Colors.AccentRed} bold> + x + </Text> + )} + </Box> + ); +}; type ToolInfo = { name: string; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 5924c7a5..6a0a9375 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -12,22 +12,4 @@ export const BOX_PADDING_X = 1; export const UI_WIDTH = EstimatedArtWidth + BOX_PADDING_X * 2 + BoxBorderWidth * 2; // ~63 -export const WITTY_LOADING_PHRASES = [ - 'Consulting the digital spirits...', - 'Reticulating splines...', - 'Warming up the AI hamsters...', - 'Asking the magic conch shell...', - 'Generating witty retort...', - 'Polishing the algorithms...', - "Don't rush perfection (or my code)...", - 'Brewing fresh bytes...', - 'Counting electrons...', - 'Engaging cognitive processors...', - 'Checking for syntax errors in the universe...', - 'One moment, optimizing humor...', - 'Shuffling punchlines...', - 'Untangling neural nets...', - 'Compiling brilliance...', -]; -export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const STREAM_DEBOUNCE_MS = 100; diff --git a/packages/cli/src/ui/contexts/StreamingContext.tsx b/packages/cli/src/ui/contexts/StreamingContext.tsx new file mode 100644 index 00000000..6144ed70 --- /dev/null +++ b/packages/cli/src/ui/contexts/StreamingContext.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext } from 'react'; +import { StreamingState } from '../types.js'; + +export interface StreamingContextType { + streamingState: StreamingState; +} + +export const StreamingContext = createContext<StreamingContextType | undefined>( + undefined, +); + +export const useStreamingContext = (): StreamingContextType => { + const context = React.useContext(StreamingContext); + if (context === undefined) { + throw new Error( + 'useStreamingContext must be used within a StreamingContextProvider', + ); + } + return context; +}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 45415f39..9dcb005b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -42,7 +42,6 @@ import { useToolScheduler, mapToDisplay } from './useToolScheduler.js'; enum StreamProcessingStatus { Completed, - PausedForConfirmation, UserCancelled, Error, } @@ -103,6 +102,21 @@ export const useGeminiStream = ( config, ); + const streamingState = useMemo(() => { + if (toolCalls.some((t) => t.status === 'awaiting_approval')) { + return StreamingState.WaitingForConfirmation; + } + if ( + isResponding || + toolCalls.some( + (t) => t.status === 'executing' || t.status === 'scheduled', + ) + ) { + return StreamingState.Responding; + } + return StreamingState.Idle; + }, [isResponding, toolCalls]); + useEffect(() => { setInitError(null); if (!geminiClientRef.current) { @@ -123,85 +137,100 @@ export const useGeminiStream = ( } }); - const prepareQueryForGemini = async ( - query: PartListUnion, - userMessageTimestamp: number, - signal: AbortSignal, - ): Promise<{ queryToSend: PartListUnion | null; shouldProceed: boolean }> => { - if (typeof query === 'string' && query.trim().length === 0) { - return { queryToSend: null, shouldProceed: false }; - } + const prepareQueryForGemini = useCallback( + async ( + query: PartListUnion, + userMessageTimestamp: number, + signal: AbortSignal, + ): Promise<{ + queryToSend: PartListUnion | null; + shouldProceed: boolean; + }> => { + if (typeof query === 'string' && query.trim().length === 0) { + return { queryToSend: null, shouldProceed: false }; + } - let localQueryToSendToGemini: PartListUnion | null = null; + let localQueryToSendToGemini: PartListUnion | null = null; - if (typeof query === 'string') { - const trimmedQuery = query.trim(); - onDebugMessage(`User query: '${trimmedQuery}'`); - await logger?.logMessage(MessageSenderType.USER, trimmedQuery); + if (typeof query === 'string') { + const trimmedQuery = query.trim(); + onDebugMessage(`User query: '${trimmedQuery}'`); + await logger?.logMessage(MessageSenderType.USER, trimmedQuery); - // Handle UI-only commands first - const slashCommandResult = handleSlashCommand(trimmedQuery); - if (typeof slashCommandResult === 'boolean' && slashCommandResult) { - // Command was handled, and it doesn't require a tool call from here - return { queryToSend: null, shouldProceed: false }; - } else if ( - typeof slashCommandResult === 'object' && - slashCommandResult.shouldScheduleTool - ) { - // Slash command wants to schedule a tool call (e.g., /memory add) - const { toolName, toolArgs } = slashCommandResult; - if (toolName && toolArgs) { - const toolCallRequest: ToolCallRequestInfo = { - callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, - name: toolName, - args: toolArgs, - }; - schedule([toolCallRequest]); // schedule expects an array or single object + // Handle UI-only commands first + const slashCommandResult = handleSlashCommand(trimmedQuery); + if (typeof slashCommandResult === 'boolean' && slashCommandResult) { + // Command was handled, and it doesn't require a tool call from here + return { queryToSend: null, shouldProceed: false }; + } else if ( + typeof slashCommandResult === 'object' && + slashCommandResult.shouldScheduleTool + ) { + // Slash command wants to schedule a tool call (e.g., /memory add) + const { toolName, toolArgs } = slashCommandResult; + if (toolName && toolArgs) { + const toolCallRequest: ToolCallRequestInfo = { + callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name: toolName, + args: toolArgs, + }; + schedule([toolCallRequest]); // schedule expects an array or single object + } + return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool } - return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool - } - if (shellModeActive && handleShellCommand(trimmedQuery)) { - return { queryToSend: null, shouldProceed: false }; - } - - // Handle @-commands (which might involve tool calls) - if (isAtCommand(trimmedQuery)) { - const atCommandResult = await handleAtCommand({ - query: trimmedQuery, - config, - addItem, - onDebugMessage, - messageId: userMessageTimestamp, - signal, - }); - if (!atCommandResult.shouldProceed) { + if (shellModeActive && handleShellCommand(trimmedQuery)) { return { queryToSend: null, shouldProceed: false }; } - localQueryToSendToGemini = atCommandResult.processedQuery; + + // Handle @-commands (which might involve tool calls) + if (isAtCommand(trimmedQuery)) { + const atCommandResult = await handleAtCommand({ + query: trimmedQuery, + config, + addItem, + onDebugMessage, + messageId: userMessageTimestamp, + signal, + }); + if (!atCommandResult.shouldProceed) { + return { queryToSend: null, shouldProceed: false }; + } + localQueryToSendToGemini = atCommandResult.processedQuery; + } else { + // Normal query for Gemini + addItem( + { type: MessageType.USER, text: trimmedQuery }, + userMessageTimestamp, + ); + localQueryToSendToGemini = trimmedQuery; + } } else { - // Normal query for Gemini - addItem( - { type: MessageType.USER, text: trimmedQuery }, - userMessageTimestamp, - ); - localQueryToSendToGemini = trimmedQuery; + // It's a function response (PartListUnion that isn't a string) + localQueryToSendToGemini = query; } - } else { - // It's a function response (PartListUnion that isn't a string) - localQueryToSendToGemini = query; - } - if (localQueryToSendToGemini === null) { - onDebugMessage( - 'Query processing resulted in null, not sending to Gemini.', - ); - return { queryToSend: null, shouldProceed: false }; - } - return { queryToSend: localQueryToSendToGemini, shouldProceed: true }; - }; + if (localQueryToSendToGemini === null) { + onDebugMessage( + 'Query processing resulted in null, not sending to Gemini.', + ); + return { queryToSend: null, shouldProceed: false }; + } + return { queryToSend: localQueryToSendToGemini, shouldProceed: true }; + }, + [ + config, + addItem, + onDebugMessage, + handleShellCommand, + handleSlashCommand, + logger, + shellModeActive, + schedule, + ], + ); - const ensureChatSession = async (): Promise<{ + const ensureChatSession = useCallback(async (): Promise<{ client: GeminiClient | null; chat: Chat | null; }> => { @@ -224,7 +253,7 @@ export const useGeminiStream = ( } } return { client: currentClient, chat: chatSessionRef.current }; - }; + }, [addItem]); // --- UI Helper Functions (used by event handlers) --- const updateFunctionResponseUI = ( @@ -285,6 +314,7 @@ export const useGeminiStream = ( history.push({ role: 'model', parts: [functionResponse] }); } updateFunctionResponseUI(responseInfo, status); + if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, Date.now()); setPendingHistoryItem(null); @@ -293,138 +323,151 @@ export const useGeminiStream = ( } // --- Stream Event Handlers --- - const handleContentEvent = ( - eventValue: ContentEvent['value'], - currentGeminiMessageBuffer: string, - userMessageTimestamp: number, - ): string => { - let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; - if ( - pendingHistoryItemRef.current?.type !== 'gemini' && - pendingHistoryItemRef.current?.type !== 'gemini_content' - ) { + + const handleContentEvent = useCallback( + ( + eventValue: ContentEvent['value'], + currentGeminiMessageBuffer: string, + userMessageTimestamp: number, + ): string => { + let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; + if ( + pendingHistoryItemRef.current?.type !== 'gemini' && + pendingHistoryItemRef.current?.type !== 'gemini_content' + ) { + if (pendingHistoryItemRef.current) { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem({ type: 'gemini', text: '' }); + newGeminiMessageBuffer = eventValue; + } + // Split large messages for better rendering performance. Ideally, + // we should maximize the amount of output sent to <Static />. + const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); + if (splitPoint === newGeminiMessageBuffer.length) { + // Update the existing message with accumulated content + setPendingHistoryItem((item) => ({ + type: item?.type as 'gemini' | 'gemini_content', + text: newGeminiMessageBuffer, + })); + } else { + // This indicates that we need to split up this Gemini Message. + // Splitting a message is primarily a performance consideration. There is a + // <Static> component at the root of App.tsx which takes care of rendering + // content statically or dynamically. Everything but the last message is + // treated as static in order to prevent re-rendering an entire message history + // multiple times per-second (as streaming occurs). Prior to this change you'd + // see heavy flickering of the terminal. This ensures that larger messages get + // broken up so that there are more "statically" rendered. + const beforeText = newGeminiMessageBuffer.substring(0, splitPoint); + const afterText = newGeminiMessageBuffer.substring(splitPoint); + addItem( + { + type: pendingHistoryItemRef.current?.type as + | 'gemini' + | 'gemini_content', + text: beforeText, + }, + userMessageTimestamp, + ); + setPendingHistoryItem({ type: 'gemini_content', text: afterText }); + newGeminiMessageBuffer = afterText; + } + return newGeminiMessageBuffer; + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem], + ); + + const handleUserCancelledEvent = useCallback( + (userMessageTimestamp: number) => { if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); + if (pendingHistoryItemRef.current.type === 'tool_group') { + const updatedTools = pendingHistoryItemRef.current.tools.map( + (tool) => + tool.status === ToolCallStatus.Pending || + tool.status === ToolCallStatus.Confirming || + tool.status === ToolCallStatus.Executing + ? { ...tool, status: ToolCallStatus.Canceled } + : tool, + ); + const pendingItem: HistoryItemToolGroup = { + ...pendingHistoryItemRef.current, + tools: updatedTools, + }; + addItem(pendingItem, userMessageTimestamp); + } else { + addItem(pendingHistoryItemRef.current, userMessageTimestamp); + } + setPendingHistoryItem(null); } - setPendingHistoryItem({ type: 'gemini', text: '' }); - newGeminiMessageBuffer = eventValue; - } - // Split large messages for better rendering performance. Ideally, - // we should maximize the amount of output sent to <Static />. - const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer); - if (splitPoint === newGeminiMessageBuffer.length) { - // Update the existing message with accumulated content - setPendingHistoryItem((item) => ({ - type: item?.type as 'gemini' | 'gemini_content', - text: newGeminiMessageBuffer, - })); - } else { - // This indicates that we need to split up this Gemini Message. - // Splitting a message is primarily a performance consideration. There is a - // <Static> component at the root of App.tsx which takes care of rendering - // content statically or dynamically. Everything but the last message is - // treated as static in order to prevent re-rendering an entire message history - // multiple times per-second (as streaming occurs). Prior to this change you'd - // see heavy flickering of the terminal. This ensures that larger messages get - // broken up so that there are more "statically" rendered. - const beforeText = newGeminiMessageBuffer.substring(0, splitPoint); - const afterText = newGeminiMessageBuffer.substring(splitPoint); addItem( - { - type: pendingHistoryItemRef.current?.type as - | 'gemini' - | 'gemini_content', - text: beforeText, - }, + { type: MessageType.INFO, text: 'User cancelled the request.' }, userMessageTimestamp, ); - setPendingHistoryItem({ type: 'gemini_content', text: afterText }); - newGeminiMessageBuffer = afterText; - } - return newGeminiMessageBuffer; - }; + setIsResponding(false); + cancel(); + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem, cancel], + ); - const handleUserCancelledEvent = (userMessageTimestamp: number) => { - if (pendingHistoryItemRef.current) { - if (pendingHistoryItemRef.current.type === 'tool_group') { - const updatedTools = pendingHistoryItemRef.current.tools.map((tool) => - tool.status === ToolCallStatus.Pending || - tool.status === ToolCallStatus.Confirming || - tool.status === ToolCallStatus.Executing - ? { ...tool, status: ToolCallStatus.Canceled } - : tool, - ); - const pendingItem: HistoryItemToolGroup = { - ...pendingHistoryItemRef.current, - tools: updatedTools, - }; - addItem(pendingItem, userMessageTimestamp); - } else { + const handleErrorEvent = useCallback( + (eventValue: ErrorEvent['value'], userMessageTimestamp: number) => { + if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); + setPendingHistoryItem(null); } - setPendingHistoryItem(null); - } - addItem( - { type: MessageType.INFO, text: 'User cancelled the request.' }, - userMessageTimestamp, - ); - setIsResponding(false); - cancel(); - }; - - const handleErrorEvent = ( - eventValue: ErrorEvent['value'], - userMessageTimestamp: number, - ) => { - if (pendingHistoryItemRef.current) { - addItem(pendingHistoryItemRef.current, userMessageTimestamp); - setPendingHistoryItem(null); - } - addItem( - { type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` }, - userMessageTimestamp, - ); - }; + addItem( + { type: MessageType.ERROR, text: `[API Error: ${eventValue.message}]` }, + userMessageTimestamp, + ); + }, + [addItem, pendingHistoryItemRef, setPendingHistoryItem], + ); - const processGeminiStreamEvents = async ( - stream: AsyncIterable<GeminiEvent>, - userMessageTimestamp: number, - ): Promise<StreamProcessingStatus> => { - let geminiMessageBuffer = ''; - const toolCallRequests: ToolCallRequestInfo[] = []; - for await (const event of stream) { - if (event.type === ServerGeminiEventType.Content) { - geminiMessageBuffer = handleContentEvent( - event.value, - geminiMessageBuffer, - userMessageTimestamp, - ); - } else if (event.type === ServerGeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); - } else if (event.type === ServerGeminiEventType.UserCancelled) { - handleUserCancelledEvent(userMessageTimestamp); - cancel(); - return StreamProcessingStatus.UserCancelled; - } else if (event.type === ServerGeminiEventType.Error) { - handleErrorEvent(event.value, userMessageTimestamp); - return StreamProcessingStatus.Error; + const processGeminiStreamEvents = useCallback( + async ( + stream: AsyncIterable<GeminiEvent>, + userMessageTimestamp: number, + ): Promise<StreamProcessingStatus> => { + let geminiMessageBuffer = ''; + const toolCallRequests: ToolCallRequestInfo[] = []; + for await (const event of stream) { + if (event.type === ServerGeminiEventType.Content) { + geminiMessageBuffer = handleContentEvent( + event.value, + geminiMessageBuffer, + userMessageTimestamp, + ); + } else if (event.type === ServerGeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } else if (event.type === ServerGeminiEventType.UserCancelled) { + handleUserCancelledEvent(userMessageTimestamp); + cancel(); + return StreamProcessingStatus.UserCancelled; + } else if (event.type === ServerGeminiEventType.Error) { + handleErrorEvent(event.value, userMessageTimestamp); + return StreamProcessingStatus.Error; + } } - } - schedule(toolCallRequests); - return StreamProcessingStatus.Completed; - }; - - const streamingState: StreamingState = - isResponding || - toolCalls.some( - (t) => t.status === 'awaiting_approval' || t.status === 'executing', - ) - ? StreamingState.Responding - : StreamingState.Idle; + schedule(toolCallRequests); + return StreamProcessingStatus.Completed; + }, + [ + handleContentEvent, + handleUserCancelledEvent, + cancel, + handleErrorEvent, + schedule, + ], + ); const submitQuery = useCallback( async (query: PartListUnion) => { - if (isResponding) return; + if ( + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation + ) + return; const userMessageTimestamp = Date.now(); setShowHelp(false); @@ -458,10 +501,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); - if ( - processingStatus === StreamProcessingStatus.PausedForConfirmation || - processingStatus === StreamProcessingStatus.UserCancelled - ) { + if (processingStatus === StreamProcessingStatus.UserCancelled) { return; } @@ -484,19 +524,16 @@ export const useGeminiStream = ( setIsResponding(false); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [ - isResponding, setShowHelp, - handleSlashCommand, - shellModeActive, - handleShellCommand, - config, addItem, - onDebugMessage, - refreshStatic, setInitError, - logger, + ensureChatSession, + prepareQueryForGemini, + processGeminiStreamEvents, + setPendingHistoryItem, + pendingHistoryItemRef, + streamingState, ], ); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts index 496e13d3..c20ded88 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts @@ -4,161 +4,124 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; import { WITTY_LOADING_PHRASES, PHRASE_CHANGE_INTERVAL_MS, -} from '../constants.js'; +} from './usePhraseCycler.js'; describe('useLoadingIndicator', () => { beforeEach(() => { vi.useFakeTimers(); + vi.clearAllMocks(); }); afterEach(() => { - vi.restoreAllMocks(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); // Restore real timers after each test }); - it('should initialize with default values when not responding', () => { + it('should initialize with default values when Idle', () => { const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Idle, false), + useLoadingIndicator(StreamingState.Idle), ); expect(result.current.elapsedTime).toBe(0); expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - expect(result.current.shouldShowSpinner).toBe(true); }); - describe('when streamingState is Responding', () => { - it('should increment elapsedTime and cycle phrases when not paused', () => { - const { result } = renderHook(() => - useLoadingIndicator(StreamingState.Responding, false), - ); - expect(result.current.shouldShowSpinner).toBe(true); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[0], - ); + it('should reflect values when Responding', () => { + const { result } = renderHook(() => + useLoadingIndicator(StreamingState.Responding), + ); - act(() => { - vi.advanceTimersByTime(1000); - }); - expect(result.current.elapsedTime).toBe(1); + // Initial state before timers advance + expect(result.current.elapsedTime).toBe(0); + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[1], - ); - expect(result.current.elapsedTime).toBe( - 1 + PHRASE_CHANGE_INTERVAL_MS / 1000, - ); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); }); + // Phrase should cycle if PHRASE_CHANGE_INTERVAL_MS has passed + // This depends on the actual implementation of usePhraseCycler + // For simplicity, we'll check it's one of the witty phrases + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[1]); + }); - it('should pause elapsedTime, show specific phrase, and hide spinner when paused', () => { - const { result, rerender } = renderHook( - ({ isPaused }) => - useLoadingIndicator(StreamingState.Responding, isPaused), - { initialProps: { isPaused: false } }, - ); + it('should show waiting phrase and retain elapsedTime when WaitingForConfirmation', () => { + const { result, rerender } = renderHook( + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, + ); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(result.current.elapsedTime).toBe(2); - expect(result.current.shouldShowSpinner).toBe(true); + act(() => { + vi.advanceTimersByTime(60000); + }); + expect(result.current.elapsedTime).toBe(60); - rerender({ isPaused: true }); + rerender({ streamingState: StreamingState.WaitingForConfirmation }); - expect(result.current.currentLoadingPhrase).toBe( - 'Waiting for user confirmation...', - ); - expect(result.current.shouldShowSpinner).toBe(false); + expect(result.current.currentLoadingPhrase).toBe( + 'Waiting for user confirmation...', + ); + expect(result.current.elapsedTime).toBe(60); // Elapsed time should be retained - // Time should not advance while paused - const timeBeforePauseAdv = result.current.elapsedTime; - act(() => { - vi.advanceTimersByTime(3000); - }); - expect(result.current.elapsedTime).toBe(timeBeforePauseAdv); + // Timer should not advance further + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.elapsedTime).toBe(60); + }); - // Unpause - rerender({ isPaused: false }); - expect(result.current.shouldShowSpinner).toBe(true); - // Phrase should reset to the beginning of witty phrases - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[0], - ); + it('should reset elapsedTime and use initial phrase when transitioning from WaitingForConfirmation to Responding', () => { + const { result, rerender } = renderHook( + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, + ); - act(() => { - vi.advanceTimersByTime(1000); - }); - // Elapsed time should resume from where it left off - expect(result.current.elapsedTime).toBe(timeBeforePauseAdv + 1); + act(() => { + vi.advanceTimersByTime(5000); // 5s }); + expect(result.current.elapsedTime).toBe(5); - it('should reset timer and phrase when streamingState changes from Responding to Idle', () => { - const { result, rerender } = renderHook( - ({ streamingState }) => useLoadingIndicator(streamingState, false), - { initialProps: { streamingState: StreamingState.Responding } }, - ); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS + 1000); - }); - expect(result.current.elapsedTime).toBe( - PHRASE_CHANGE_INTERVAL_MS / 1000 + 1, - ); - expect(result.current.currentLoadingPhrase).toBe( - WITTY_LOADING_PHRASES[1], - ); + rerender({ streamingState: StreamingState.WaitingForConfirmation }); + expect(result.current.elapsedTime).toBe(5); + expect(result.current.currentLoadingPhrase).toBe( + 'Waiting for user confirmation...', + ); - rerender({ streamingState: StreamingState.Idle }); + rerender({ streamingState: StreamingState.Responding }); + expect(result.current.elapsedTime).toBe(0); // Should reset + expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); - expect(result.current.elapsedTime).toBe(0); - // When idle, the phrase interval should be cleared, but the last phrase might persist - // until the next "Responding" state. The important part is that the timer is reset. - // Depending on exact timing, it might be the last witty phrase or the first. - // For this test, we'll ensure it's one of them. - expect(WITTY_LOADING_PHRASES).toContain( - result.current.currentLoadingPhrase, - ); + act(() => { + vi.advanceTimersByTime(1000); }); + expect(result.current.elapsedTime).toBe(1); }); - it('should clear intervals on unmount', () => { - const { unmount } = renderHook(() => - useLoadingIndicator(StreamingState.Responding, false), - ); - const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); - unmount(); - // Expecting two intervals (elapsedTime and phraseInterval) to be cleared. - expect(clearIntervalSpy).toHaveBeenCalledTimes(2); - }); - - it('should reset to initial witty phrase when unpaused', () => { + it('should reset timer and phrase when streamingState changes from Responding to Idle', () => { const { result, rerender } = renderHook( - ({ isPaused }) => - useLoadingIndicator(StreamingState.Responding, isPaused), - { initialProps: { isPaused: false } }, + ({ streamingState }) => useLoadingIndicator(streamingState), + { initialProps: { streamingState: StreamingState.Responding } }, ); - // Advance to the second witty phrase act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + vi.advanceTimersByTime(10000); // 10s }); - expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[1]); + expect(result.current.elapsedTime).toBe(10); - // Pause - rerender({ isPaused: true }); - expect(result.current.currentLoadingPhrase).toBe( - 'Waiting for user confirmation...', - ); + rerender({ streamingState: StreamingState.Idle }); - // Unpause - rerender({ isPaused: false }); + expect(result.current.elapsedTime).toBe(0); expect(result.current.currentLoadingPhrase).toBe(WITTY_LOADING_PHRASES[0]); + + // Timer should not advance + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.elapsedTime).toBe(0); }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index ac75986a..46754ac1 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -4,98 +4,54 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useRef } from 'react'; -import { - WITTY_LOADING_PHRASES, - PHRASE_CHANGE_INTERVAL_MS, -} from '../constants.js'; import { StreamingState } from '../types.js'; +import { useTimer } from './useTimer.js'; +import { usePhraseCycler } from './usePhraseCycler.js'; +import { useState, useEffect, useRef } from 'react'; // Added useRef -export const useLoadingIndicator = ( - streamingState: StreamingState, - isPaused: boolean, -) => { - const [elapsedTime, setElapsedTime] = useState(0); - const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( - WITTY_LOADING_PHRASES[0], - ); - const timerRef = useRef<NodeJS.Timeout | null>(null); - const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); - const currentPhraseIndexRef = useRef<number>(0); +export const useLoadingIndicator = (streamingState: StreamingState) => { + const [timerResetKey, setTimerResetKey] = useState(0); + const isTimerActive = streamingState === StreamingState.Responding; - const [shouldShowSpinner, setShouldShowSpinner] = useState(true); + const elapsedTimeFromTimer = useTimer(isTimerActive, timerResetKey); - useEffect(() => { - if (streamingState === StreamingState.Responding) { - if (!isPaused) { - if (!timerRef.current) { - // No specific action needed here if timer wasn't running and we are not paused. - // Elapsed time continues from where it left off or starts from 0 if it's a fresh start. - } - if (timerRef.current) clearInterval(timerRef.current); - timerRef.current = setInterval(() => { - setElapsedTime((prevTime) => prevTime + 1); - }, 1000); - } else { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - } - } else { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setElapsedTime(0); - } + const isPhraseCyclingActive = streamingState === StreamingState.Responding; + const isWaiting = streamingState === StreamingState.WaitingForConfirmation; + const currentLoadingPhrase = usePhraseCycler( + isPhraseCyclingActive, + isWaiting, + ); - return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [streamingState, isPaused]); + const [retainedElapsedTime, setRetainedElapsedTime] = useState(0); + const prevStreamingStateRef = useRef<StreamingState | null>(null); useEffect(() => { - if (streamingState === StreamingState.Responding) { - if (!isPaused) { - setShouldShowSpinner(true); - if (!phraseIntervalRef.current) { - currentPhraseIndexRef.current = 0; - setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); - phraseIntervalRef.current = setInterval(() => { - currentPhraseIndexRef.current = - (currentPhraseIndexRef.current + 1) % - WITTY_LOADING_PHRASES.length; - setCurrentLoadingPhrase( - WITTY_LOADING_PHRASES[currentPhraseIndexRef.current], - ); - }, PHRASE_CHANGE_INTERVAL_MS); - } - } else { - setShouldShowSpinner(false); - setCurrentLoadingPhrase('Waiting for user confirmation...'); - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - } - } else { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } + if ( + prevStreamingStateRef.current === StreamingState.WaitingForConfirmation && + streamingState === StreamingState.Responding + ) { + setTimerResetKey((prevKey) => prevKey + 1); + setRetainedElapsedTime(0); // Clear retained time when going back to responding + } else if ( + streamingState === StreamingState.Idle && + prevStreamingStateRef.current === StreamingState.Responding + ) { + setTimerResetKey((prevKey) => prevKey + 1); // Reset timer when becoming idle from responding + setRetainedElapsedTime(0); + } else if (streamingState === StreamingState.WaitingForConfirmation) { + // Capture the time when entering WaitingForConfirmation + // elapsedTimeFromTimer will hold the last value from when isTimerActive was true. + setRetainedElapsedTime(elapsedTimeFromTimer); } - return () => { - if (phraseIntervalRef.current) { - clearInterval(phraseIntervalRef.current); - phraseIntervalRef.current = null; - } - }; - }, [streamingState, isPaused]); + prevStreamingStateRef.current = streamingState; + }, [streamingState, elapsedTimeFromTimer]); - return { elapsedTime, currentLoadingPhrase, shouldShowSpinner }; + return { + elapsedTime: + streamingState === StreamingState.WaitingForConfirmation + ? retainedElapsedTime + : elapsedTimeFromTimer, + currentLoadingPhrase, + }; }; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts new file mode 100644 index 00000000..f5be12e9 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + usePhraseCycler, + WITTY_LOADING_PHRASES, + PHRASE_CHANGE_INTERVAL_MS, +} from './usePhraseCycler.js'; + +describe('usePhraseCycler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with the first witty phrase when not active and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(false, false)); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); + + it('should show "Waiting for user confirmation..." when isWaiting is true', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: true, isWaiting: false } }, + ); + rerender({ isActive: true, isWaiting: true }); + expect(result.current).toBe('Waiting for user confirmation...'); + }); + + it('should not cycle phrases if isActive is false and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(false, false)); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS * 2); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); + + it('should cycle through witty phrases when isActive is true and not waiting', () => { + const { result } = renderHook(() => usePhraseCycler(true, false)); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); + + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe( + WITTY_LOADING_PHRASES[2 % WITTY_LOADING_PHRASES.length], + ); + }); + + it('should reset to the first phrase when isActive becomes true after being false (and not waiting)', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: false, isWaiting: false } }, + ); + // Cycle to a different phrase + rerender({ isActive: true, isWaiting: false }); + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).not.toBe(WITTY_LOADING_PHRASES[0]); + + // Set to inactive + rerender({ isActive: false, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); // Should reset to first phrase + + // Set back to active + rerender({ isActive: true, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); // Should start with the first phrase + }); + + it('should clear phrase interval on unmount when active', () => { + const { unmount } = renderHook(() => usePhraseCycler(true, false)); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should reset to the first witty phrase when transitioning from waiting to active', () => { + const { result, rerender } = renderHook( + ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), + { initialProps: { isActive: true, isWaiting: false } }, + ); + + // Cycle to a different phrase + act(() => { + vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); + }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); + + // Go to waiting state + rerender({ isActive: false, isWaiting: true }); + expect(result.current).toBe('Waiting for user confirmation...'); + + // Go back to active cycling + rerender({ isActive: true, isWaiting: false }); + expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); + }); +}); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts new file mode 100644 index 00000000..96bad676 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; + +export const WITTY_LOADING_PHRASES = [ + 'Consulting the digital spirits...', + 'Reticulating splines...', + 'Warming up the AI hamsters...', + 'Asking the magic conch shell...', + 'Generating witty retort...', + 'Polishing the algorithms...', + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...', + 'Counting electrons...', + 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...', + 'Shuffling punchlines...', + 'Untangling neural nets...', + 'Compiling brilliance...', +]; + +export const PHRASE_CHANGE_INTERVAL_MS = 15000; + +/** + * Custom hook to manage cycling through loading phrases. + * @param isActive Whether the phrase cycling should be active. + * @param isWaiting Whether to show a specific waiting phrase. + * @returns The current loading phrase. + */ +export const usePhraseCycler = (isActive: boolean, isWaiting: boolean) => { + const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( + WITTY_LOADING_PHRASES[0], + ); + const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); + const currentPhraseIndexRef = useRef<number>(0); + + useEffect(() => { + if (isWaiting) { + setCurrentLoadingPhrase('Waiting for user confirmation...'); + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + } else if (isActive) { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + } + // Reset to the first witty phrase when starting to respond + currentPhraseIndexRef.current = 0; + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + + phraseIntervalRef.current = setInterval(() => { + currentPhraseIndexRef.current = + (currentPhraseIndexRef.current + 1) % WITTY_LOADING_PHRASES.length; + setCurrentLoadingPhrase( + WITTY_LOADING_PHRASES[currentPhraseIndexRef.current], + ); + }, PHRASE_CHANGE_INTERVAL_MS); + } else { + // Idle or other states, clear the phrase interval + // and reset to the first phrase for next active state. + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + setCurrentLoadingPhrase(WITTY_LOADING_PHRASES[0]); + currentPhraseIndexRef.current = 0; + } + + return () => { + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + }; + }, [isActive, isWaiting]); + + return currentLoadingPhrase; +}; diff --git a/packages/cli/src/ui/hooks/useTimer.test.ts b/packages/cli/src/ui/hooks/useTimer.test.ts new file mode 100644 index 00000000..20d44d17 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimer.test.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTimer } from './useTimer.js'; + +describe('useTimer', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with 0', () => { + const { result } = renderHook(() => useTimer(false, 0)); + expect(result.current).toBe(0); + }); + + it('should not increment time if isActive is false', () => { + const { result } = renderHook(() => useTimer(false, 0)); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(0); + }); + + it('should increment time every second if isActive is true', () => { + const { result } = renderHook(() => useTimer(true, 0)); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBe(3); + }); + + it('should reset to 0 and start incrementing when isActive becomes true from false', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: false, resetKey: 0 } }, + ); + expect(result.current).toBe(0); + + rerender({ isActive: true, resetKey: 0 }); + expect(result.current).toBe(0); // Should reset to 0 upon becoming active + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + }); + + it('should reset to 0 when resetKey changes while active', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: true, resetKey: 0 } }, + ); + act(() => { + vi.advanceTimersByTime(3000); // 3s + }); + expect(result.current).toBe(3); + + rerender({ isActive: true, resetKey: 1 }); // Change resetKey + expect(result.current).toBe(0); // Should reset to 0 + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); // Starts incrementing from 0 + }); + + it('should be 0 if isActive is false, regardless of resetKey changes', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: false, resetKey: 0 } }, + ); + expect(result.current).toBe(0); + + rerender({ isActive: false, resetKey: 1 }); + expect(result.current).toBe(0); + }); + + it('should clear timer on unmount', () => { + const { unmount } = renderHook(() => useTimer(true, 0)); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should preserve elapsedTime when isActive becomes false, and reset to 0 when it becomes active again', () => { + const { result, rerender } = renderHook( + ({ isActive, resetKey }) => useTimer(isActive, resetKey), + { initialProps: { isActive: true, resetKey: 0 } }, + ); + + act(() => { + vi.advanceTimersByTime(3000); // Advance to 3 seconds + }); + expect(result.current).toBe(3); + + rerender({ isActive: false, resetKey: 0 }); + expect(result.current).toBe(3); // Time should be preserved when timer becomes inactive + + // Now make it active again, it should reset to 0 + rerender({ isActive: true, resetKey: 0 }); + expect(result.current).toBe(0); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current).toBe(1); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTimer.ts b/packages/cli/src/ui/hooks/useTimer.ts new file mode 100644 index 00000000..53c266dd --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimer.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; + +/** + * Custom hook to manage a timer that increments every second. + * @param isActive Whether the timer should be running. + * @param resetKey A key that, when changed, will reset the timer to 0 and restart the interval. + * @returns The elapsed time in seconds. + */ +export const useTimer = (isActive: boolean, resetKey: unknown) => { + const [elapsedTime, setElapsedTime] = useState(0); + const timerRef = useRef<NodeJS.Timeout | null>(null); + const prevResetKeyRef = useRef(resetKey); + const prevIsActiveRef = useRef(isActive); + + useEffect(() => { + let shouldResetTime = false; + + if (prevResetKeyRef.current !== resetKey) { + shouldResetTime = true; + prevResetKeyRef.current = resetKey; + } + + if (prevIsActiveRef.current === false && isActive) { + // Transitioned from inactive to active + shouldResetTime = true; + } + + if (shouldResetTime) { + setElapsedTime(0); + } + prevIsActiveRef.current = isActive; + + // Manage interval + if (isActive) { + // Clear previous interval unconditionally before starting a new one + // This handles resetKey changes while active, ensuring a fresh interval start. + if (timerRef.current) { + clearInterval(timerRef.current); + } + timerRef.current = setInterval(() => { + setElapsedTime((prev) => prev + 1); + }, 1000); + } else { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + }, [isActive, resetKey]); + + return elapsedTime; +}; |
