From d351f07758523d8fd4ccdceade317e745e9583d9 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 21 Nov 2025 12:19:34 -0500 Subject: [PATCH] feat: custom loading phrase when interactive shell requires input (#12535) --- packages/cli/src/ui/AppContainer.tsx | 3 + .../src/ui/components/LoadingIndicator.tsx | 8 +- .../components/messages/ShellToolMessage.tsx | 8 +- .../ui/components/messages/ToolMessage.tsx | 146 +++++++++++++----- packages/cli/src/ui/constants.ts | 2 + .../cli/src/ui/hooks/shellCommandProcessor.ts | 5 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 32 ++-- .../cli/src/ui/hooks/useInactivityTimer.ts | 39 +++++ .../src/ui/hooks/useLoadingIndicator.test.tsx | 54 ++++++- .../cli/src/ui/hooks/useLoadingIndicator.ts | 4 + .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 109 ++++++++++++- packages/cli/src/ui/hooks/usePhraseCycler.ts | 113 ++++++++------ .../cli/src/ui/hooks/useReactToolScheduler.ts | 4 + 13 files changed, 420 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useInactivityTimer.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ffe7aa51d0..2baf93f4d5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -713,6 +713,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, + lastOutputTime, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1112,6 +1113,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( streamingState, settings.merged.ui?.customWittyPhrases, + !!activePtyId && !embeddedShellFocused, + lastOutputTime, ); const handleGlobalKeypress = useCallback( diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index bd0b7e81f9..4917946d3a 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { formatDuration } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; @@ -36,7 +37,12 @@ export const LoadingIndicator: React.FC = ({ return null; } - const primaryText = thought?.subject || currentLoadingPhrase; + // Prioritize the interactive shell waiting phrase over the thought subject + // because it conveys an actionable state for the user (waiting for input). + const primaryText = + currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE + ? currentLoadingPhrase + : thought?.subject || currentLoadingPhrase; const cancelAndTimerContent = streamingState !== StreamingState.WaitingForConfirmation diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 12d322c4fa..a9197f15c5 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -9,7 +9,11 @@ import { Box, Text, type DOMElement } from 'ink'; import { ToolCallStatus } from '../../types.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { StickyHeader } from '../StickyHeader.js'; -import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; +import { + SHELL_COMMAND_NAME, + SHELL_NAME, + SHELL_FOCUS_HINT_DELAY_MS, +} from '../../constants.js'; import { theme } from '../../semantic-colors.js'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { useUIActions } from '../../contexts/UIActionsContext.js'; @@ -104,7 +108,7 @@ export const ShellToolMessage: React.FC = ({ const timer = setTimeout(() => { setShowFocusHint(true); - }, 5000); + }, SHELL_FOCUS_HINT_DELAY_MS); return () => clearTimeout(timer); }, [lastUpdateTime]); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index d2d41d7766..86ad6968d8 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -5,7 +5,8 @@ */ import type React from 'react'; -import { Box } from 'ink'; +import { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { StickyHeader } from '../StickyHeader.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; @@ -14,7 +15,17 @@ import { ToolInfo, TrailingIndicator, type TextEmphasis, + STATUS_INDICATOR_WIDTH, } from './ToolShared.js'; +import { + SHELL_COMMAND_NAME, + SHELL_FOCUS_HINT_DELAY_MS, +} from '../../constants.js'; +import { theme } from '../../semantic-colors.js'; +import type { Config } from '@google/gemini-cli-core'; +import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; +import { ToolCallStatus } from '../../types.js'; +import { ShellInputPrompt } from '../ShellInputPrompt.js'; export type { TextEmphasis }; @@ -26,6 +37,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { isFirst: boolean; borderColor: string; borderDimColor: boolean; + activeShellPtyId?: number | null; + embeddedShellFocused?: boolean; + ptyId?: number; + config?: Config; } export const ToolMessage: React.FC = ({ @@ -40,41 +55,96 @@ export const ToolMessage: React.FC = ({ isFirst, borderColor, borderDimColor, -}) => ( - - - - - {emphasis === 'high' && } - - - + activeShellPtyId, + embeddedShellFocused, + ptyId, + config, +}) => { + const isThisShellFocused = + (name === SHELL_COMMAND_NAME || name === 'Shell') && + status === ToolCallStatus.Executing && + ptyId === activeShellPtyId && + embeddedShellFocused; + + const [lastUpdateTime, setLastUpdateTime] = useState(null); + const [userHasFocused, setUserHasFocused] = useState(false); + const showFocusHint = useInactivityTimer( + !!lastUpdateTime, + lastUpdateTime ? lastUpdateTime.getTime() : 0, + SHELL_FOCUS_HINT_DELAY_MS, + ); + + useEffect(() => { + if (resultDisplay) { + setLastUpdateTime(new Date()); + } + }, [resultDisplay]); + + useEffect(() => { + if (isThisShellFocused) { + setUserHasFocused(true); + } + }, [isThisShellFocused]); + + const isThisShellFocusable = + (name === SHELL_COMMAND_NAME || name === 'Shell') && + status === ToolCallStatus.Executing && + config?.getEnableInteractiveShell(); + + const shouldShowFocusHint = + isThisShellFocusable && (showFocusHint || userHasFocused); + + return ( + + + + + {shouldShowFocusHint && ( + + + {isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'} + + + )} + {emphasis === 'high' && } + + + + {isThisShellFocused && config && ( + + + + )} + - -); + ); +}; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 143556f003..8dd58846c3 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -24,6 +24,8 @@ export const SHELL_NAME = 'Shell'; // usage. export const MAX_GEMINI_MESSAGE_LINES = 65536; +export const SHELL_FOCUS_HINT_DELAY_MS = 5000; + // Tool status symbols used in ToolMessage component export const TOOL_STATUS = { SUCCESS: '✓', diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index a463c0f4df..4fee562cba 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -76,6 +76,8 @@ export const useShellCommandProcessor = ( terminalHeight?: number, ) => { const [activeShellPtyId, setActiveShellPtyId] = useState(null); + const [lastShellOutputTime, setLastShellOutputTime] = useState(0); + const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { @@ -202,6 +204,7 @@ export const useShellCommandProcessor = ( // Throttle pending UI updates, but allow forced updates. if (shouldUpdate) { + setLastShellOutputTime(Date.now()); setPendingHistoryItem((prevItem) => { if (prevItem?.type === 'tool_group') { return { @@ -366,5 +369,5 @@ export const useShellCommandProcessor = ( ], ); - return { handleShellCommand, activeShellPtyId }; + return { handleShellCommand, activeShellPtyId, lastShellOutputTime }; }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 37a0848e56..979f520dcd 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -136,6 +136,7 @@ export const useGeminiStream = ( markToolsAsSubmitted, setToolCallsForDisplay, cancelAllToolCalls, + lastToolOutputTime, ] = useReactToolScheduler( async (completedToolCallsFromScheduler) => { // This onComplete is called when ALL scheduled tools for a given batch are done. @@ -211,17 +212,18 @@ export const useGeminiStream = ( await done; setIsResponding(false); }, []); - const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor( - addItem, - setPendingHistoryItem, - onExec, - onDebugMessage, - config, - geminiClient, - setShellInputFocused, - terminalWidth, - terminalHeight, - ); + const { handleShellCommand, activeShellPtyId, lastShellOutputTime } = + useShellCommandProcessor( + addItem, + setPendingHistoryItem, + onExec, + onDebugMessage, + config, + geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, + ); const activePtyId = activeShellPtyId || activeToolPtyId; @@ -681,8 +683,9 @@ export const useGeminiStream = ( [FinishReason.UNEXPECTED_TOOL_CALL]: 'Response stopped due to unexpected tool call.', [FinishReason.IMAGE_PROHIBITED_CONTENT]: - 'Response stopped due to prohibited content.', - [FinishReason.NO_IMAGE]: 'Response stopped due to no image.', + 'Response stopped due to prohibited image content.', + [FinishReason.NO_IMAGE]: + 'Response stopped because no image was generated.', }; const message = finishReasonMessages[finishReason]; @@ -1348,6 +1351,8 @@ export const useGeminiStream = ( storage, ]); + const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime); + return { streamingState, submitQuery, @@ -1359,5 +1364,6 @@ export const useGeminiStream = ( handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, + lastOutputTime, }; }; diff --git a/packages/cli/src/ui/hooks/useInactivityTimer.ts b/packages/cli/src/ui/hooks/useInactivityTimer.ts new file mode 100644 index 0000000000..b4e667a358 --- /dev/null +++ b/packages/cli/src/ui/hooks/useInactivityTimer.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; + +/** + * Returns true after a specified delay of inactivity. + * Inactivity is defined as 'trigger' not changing for 'delayMs' milliseconds. + * + * @param isActive Whether the timer should be running. + * @param trigger Any value that, when changed, resets the inactivity timer. + * @param delayMs The delay in milliseconds before considering the state inactive. + */ +export const useInactivityTimer = ( + isActive: boolean, + trigger: unknown, + delayMs: number = 5000, +): boolean => { + const [isInactive, setIsInactive] = useState(false); + + useEffect(() => { + if (!isActive) { + setIsInactive(false); + return; + } + + setIsInactive(false); + const timer = setTimeout(() => { + setIsInactive(true); + }, delayMs); + + return () => clearTimeout(timer); + }, [isActive, trigger, delayMs]); + + return isInactive; +}; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index c714f60c14..6b1ec2189f 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -9,8 +9,12 @@ import { act } from 'react'; import { render } from '../../test-utils/render.js'; import { useLoadingIndicator } from './useLoadingIndicator.js'; import { StreamingState } from '../types.js'; -import { PHRASE_CHANGE_INTERVAL_MS } from './usePhraseCycler.js'; +import { + PHRASE_CHANGE_INTERVAL_MS, + INTERACTIVE_SHELL_WAITING_PHRASE, +} from './usePhraseCycler.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import { INFORMATIVE_TIPS } from '../constants/tips.js'; describe('useLoadingIndicator', () => { beforeEach(() => { @@ -25,18 +29,33 @@ describe('useLoadingIndicator', () => { const renderLoadingIndicatorHook = ( initialStreamingState: StreamingState, + initialIsInteractiveShellWaiting: boolean = false, + initialLastOutputTime: number = 0, ) => { let hookResult: ReturnType; function TestComponent({ streamingState, + isInteractiveShellWaiting, + lastOutputTime, }: { streamingState: StreamingState; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; }) { - hookResult = useLoadingIndicator(streamingState); + hookResult = useLoadingIndicator( + streamingState, + undefined, + isInteractiveShellWaiting, + lastOutputTime, + ); return null; } const { rerender } = render( - , + , ); return { result: { @@ -44,8 +63,11 @@ describe('useLoadingIndicator', () => { return hookResult; }, }, - rerender: (newProps: { streamingState: StreamingState }) => - rerender(), + rerender: (newProps: { + streamingState: StreamingState; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; + }) => rerender(), }; }; @@ -58,6 +80,28 @@ describe('useLoadingIndicator', () => { ); }); + it('should show interactive shell waiting phrase when isInteractiveShellWaiting is true after 5s', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { result } = renderLoadingIndicatorHook( + StreamingState.Responding, + true, + 1, + ); + + // Initially should be witty phrase or tip + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + result.current.currentLoadingPhrase, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + expect(result.current.currentLoadingPhrase).toBe( + INTERACTIVE_SHELL_WAITING_PHRASE, + ); + }); + it('should reflect values when Responding', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases const { result } = renderLoadingIndicatorHook(StreamingState.Responding); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index d69df1706d..a39b0c0e29 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -12,6 +12,8 @@ import { useState, useEffect, useRef } from 'react'; // Added useRef export const useLoadingIndicator = ( streamingState: StreamingState, customWittyPhrases?: string[], + isInteractiveShellWaiting: boolean = false, + lastOutputTime: number = 0, ) => { const [timerResetKey, setTimerResetKey] = useState(0); const isTimerActive = streamingState === StreamingState.Responding; @@ -23,6 +25,8 @@ export const useLoadingIndicator = ( const currentLoadingPhrase = usePhraseCycler( isPhraseCyclingActive, isWaiting, + isInteractiveShellWaiting, + lastOutputTime, customWittyPhrases, ); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 576b8266ca..cefa800afd 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -11,6 +11,7 @@ import { Text } from 'ink'; import { usePhraseCycler, PHRASE_CHANGE_INTERVAL_MS, + INTERACTIVE_SHELL_WAITING_PHRASE, } from './usePhraseCycler.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; @@ -19,13 +20,23 @@ import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; const TestComponent = ({ isActive, isWaiting, + isInteractiveShellWaiting = false, + lastOutputTime = 0, customPhrases, }: { isActive: boolean; isWaiting: boolean; + isInteractiveShellWaiting?: boolean; + lastOutputTime?: number; customPhrases?: string[]; }) => { - const phrase = usePhraseCycler(isActive, isWaiting, customPhrases); + const phrase = usePhraseCycler( + isActive, + isWaiting, + isInteractiveShellWaiting, + lastOutputTime, + customPhrases, + ); return {phrase}; }; @@ -57,6 +68,102 @@ describe('usePhraseCycler', () => { expect(lastFrame()).toBe('Waiting for user confirmation...'); }); + it('should show interactive shell waiting message when isInteractiveShellWaiting is true after 5s', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame, rerender } = render( + , + ); + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + // Should still be showing a witty phrase or tip initially + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + + it('should reset interactive shell waiting timer when lastOutputTime changes', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame, rerender } = render( + , + ); + + // Advance 3 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + // Should still be witty phrase or tip + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + // Update lastOutputTime + rerender( + , + ); + + // Advance another 3 seconds (total 6s from start, but only 3s from last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + // Should STILL be witty phrase or tip because timer reset + expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain( + lastFrame(), + ); + + // Advance another 2 seconds (total 5s from last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + + it('should prioritize interactive shell waiting over normal waiting after 5s', async () => { + const { lastFrame, rerender } = render( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(lastFrame()).toBe('Waiting for user confirmation...'); + + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + }); + it('should not cycle phrases if isActive is false and not waiting', async () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 8985e68b1b..969fe47135 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -5,20 +5,28 @@ */ import { useState, useEffect, useRef } from 'react'; +import { SHELL_FOCUS_HINT_DELAY_MS } from '../constants.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; +import { useInactivityTimer } from './useInactivityTimer.js'; export const PHRASE_CHANGE_INTERVAL_MS = 15000; +export const INTERACTIVE_SHELL_WAITING_PHRASE = + 'Interactive shell awaiting input... press Ctrl+f to focus shell'; /** * 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. + * @param isInteractiveShellWaiting Whether an interactive shell is waiting for input but not focused. + * @param customPhrases Optional list of custom phrases to use. * @returns The current loading phrase. */ export const usePhraseCycler = ( isActive: boolean, isWaiting: boolean, + isInteractiveShellWaiting: boolean, + lastOutputTime: number = 0, customPhrases?: string[], ) => { const loadingPhrases = @@ -29,66 +37,79 @@ export const usePhraseCycler = ( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( loadingPhrases[0], ); + const showShellFocusHint = useInactivityTimer( + isInteractiveShellWaiting && lastOutputTime > 0, + lastOutputTime, + SHELL_FOCUS_HINT_DELAY_MS, + ); const phraseIntervalRef = useRef(null); const hasShownFirstRequestTipRef = useRef(false); useEffect(() => { + // Always clear on re-run + if (phraseIntervalRef.current) { + clearInterval(phraseIntervalRef.current); + phraseIntervalRef.current = null; + } + + if (isInteractiveShellWaiting && showShellFocusHint) { + setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE); + return; + } + 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); - } - - const setRandomPhrase = () => { - if (customPhrases && customPhrases.length > 0) { - const randomIndex = Math.floor(Math.random() * customPhrases.length); - setCurrentLoadingPhrase(customPhrases[randomIndex]); - } else { - let phraseList; - // Show a tip on the first request after startup, then continue with 1/6 chance - if (!hasShownFirstRequestTipRef.current) { - // Show a tip during the first request - phraseList = INFORMATIVE_TIPS; - hasShownFirstRequestTipRef.current = true; - } else { - // Roughly 1 in 6 chance to show a tip after the first request - const showTip = Math.random() < 1 / 6; - phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES; - } - const randomIndex = Math.floor(Math.random() * phraseList.length); - setCurrentLoadingPhrase(phraseList[randomIndex]); - } - }; - - // Select an initial random phrase - setRandomPhrase(); - - phraseIntervalRef.current = setInterval(() => { - // Select a new random phrase - setRandomPhrase(); - }, 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(loadingPhrases[0]); + return; } + if (!isActive) { + setCurrentLoadingPhrase(loadingPhrases[0]); + return; + } + + const setRandomPhrase = () => { + if (customPhrases && customPhrases.length > 0) { + const randomIndex = Math.floor(Math.random() * customPhrases.length); + setCurrentLoadingPhrase(customPhrases[randomIndex]); + } else { + let phraseList; + // Show a tip on the first request after startup, then continue with 1/6 chance + if (!hasShownFirstRequestTipRef.current) { + // Show a tip during the first request + phraseList = INFORMATIVE_TIPS; + hasShownFirstRequestTipRef.current = true; + } else { + // Roughly 1 in 6 chance to show a tip after the first request + const showTip = Math.random() < 1 / 6; + phraseList = showTip ? INFORMATIVE_TIPS : WITTY_LOADING_PHRASES; + } + const randomIndex = Math.floor(Math.random() * phraseList.length); + setCurrentLoadingPhrase(phraseList[randomIndex]); + } + }; + + // Select an initial random phrase + setRandomPhrase(); + + phraseIntervalRef.current = setInterval(() => { + // Select a new random phrase + setRandomPhrase(); + }, PHRASE_CHANGE_INTERVAL_MS); + return () => { if (phraseIntervalRef.current) { clearInterval(phraseIntervalRef.current); phraseIntervalRef.current = null; } }; - }, [isActive, isWaiting, customPhrases, loadingPhrases]); + }, [ + isActive, + isWaiting, + isInteractiveShellWaiting, + customPhrases, + loadingPhrases, + showShellFocusHint, + ]); return currentLoadingPhrase; }; diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 0b00ce4d78..9c21fe2bcc 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -74,10 +74,12 @@ export function useReactToolScheduler( MarkToolsAsSubmittedFn, React.Dispatch>, CancelAllFn, + number, ] { const [toolCallsForDisplay, setToolCallsForDisplay] = useState< TrackedToolCall[] >([]); + const [lastToolOutputTime, setLastToolOutputTime] = useState(0); // Store callbacks in refs to keep them up-to-date without causing re-renders. const onCompleteRef = useRef(onComplete); @@ -93,6 +95,7 @@ export function useReactToolScheduler( const outputUpdateHandler: OutputUpdateHandler = useCallback( (toolCallId, outputChunk) => { + setLastToolOutputTime(Date.now()); setToolCallsForDisplay((prevCalls) => prevCalls.map((tc) => { if (tc.request.callId === toolCallId && tc.status === 'executing') { @@ -208,6 +211,7 @@ export function useReactToolScheduler( markToolsAsSubmitted, setToolCallsForDisplay, cancelAllToolCalls, + lastToolOutputTime, ]; }