feat: custom loading phrase when interactive shell requires input (#12535)

This commit is contained in:
Jack Wotherspoon
2025-11-21 12:19:34 -05:00
committed by GitHub
parent d72f35c2e7
commit d351f07758
13 changed files with 420 additions and 107 deletions

View File

@@ -713,6 +713,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApprovalModeChange, handleApprovalModeChange,
activePtyId, activePtyId,
loopDetectionConfirmationRequest, loopDetectionConfirmationRequest,
lastOutputTime,
} = useGeminiStream( } = useGeminiStream(
config.getGeminiClient(), config.getGeminiClient(),
historyManager.history, historyManager.history,
@@ -1112,6 +1113,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState, streamingState,
settings.merged.ui?.customWittyPhrases, settings.merged.ui?.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
); );
const handleGlobalKeypress = useCallback( const handleGlobalKeypress = useCallback(

View File

@@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps { interface LoadingIndicatorProps {
currentLoadingPhrase?: string; currentLoadingPhrase?: string;
@@ -36,7 +37,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
return null; 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 = const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation streamingState !== StreamingState.WaitingForConfirmation

View File

@@ -9,7 +9,11 @@ import { Box, Text, type DOMElement } from 'ink';
import { ToolCallStatus } from '../../types.js'; import { ToolCallStatus } from '../../types.js';
import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js';
import { StickyHeader } from '../StickyHeader.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 { theme } from '../../semantic-colors.js';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { useUIActions } from '../../contexts/UIActionsContext.js'; import { useUIActions } from '../../contexts/UIActionsContext.js';
@@ -104,7 +108,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowFocusHint(true); setShowFocusHint(true);
}, 5000); }, SHELL_FOCUS_HINT_DELAY_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [lastUpdateTime]); }, [lastUpdateTime]);

View File

@@ -5,7 +5,8 @@
*/ */
import type React from 'react'; 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 type { IndividualToolCallDisplay } from '../../types.js';
import { StickyHeader } from '../StickyHeader.js'; import { StickyHeader } from '../StickyHeader.js';
import { ToolResultDisplay } from './ToolResultDisplay.js'; import { ToolResultDisplay } from './ToolResultDisplay.js';
@@ -14,7 +15,17 @@ import {
ToolInfo, ToolInfo,
TrailingIndicator, TrailingIndicator,
type TextEmphasis, type TextEmphasis,
STATUS_INDICATOR_WIDTH,
} from './ToolShared.js'; } 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 }; export type { TextEmphasis };
@@ -26,6 +37,10 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
isFirst: boolean; isFirst: boolean;
borderColor: string; borderColor: string;
borderDimColor: boolean; borderDimColor: boolean;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
ptyId?: number;
config?: Config;
} }
export const ToolMessage: React.FC<ToolMessageProps> = ({ export const ToolMessage: React.FC<ToolMessageProps> = ({
@@ -40,41 +55,96 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
isFirst, isFirst,
borderColor, borderColor,
borderDimColor, borderDimColor,
}) => ( activeShellPtyId,
<Box flexDirection="column" width={terminalWidth}> embeddedShellFocused,
<StickyHeader ptyId,
width={terminalWidth} config,
isFirst={isFirst} }) => {
borderColor={borderColor} const isThisShellFocused =
borderDimColor={borderDimColor} (name === SHELL_COMMAND_NAME || name === 'Shell') &&
> status === ToolCallStatus.Executing &&
<ToolStatusIndicator status={status} name={name} /> ptyId === activeShellPtyId &&
<ToolInfo embeddedShellFocused;
name={name}
status={status} const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
description={description} const [userHasFocused, setUserHasFocused] = useState(false);
emphasis={emphasis} const showFocusHint = useInactivityTimer(
/> !!lastUpdateTime,
{emphasis === 'high' && <TrailingIndicator />} lastUpdateTime ? lastUpdateTime.getTime() : 0,
</StickyHeader> SHELL_FOCUS_HINT_DELAY_MS,
<Box );
width={terminalWidth}
borderStyle="round" useEffect(() => {
borderColor={borderColor} if (resultDisplay) {
borderDimColor={borderDimColor} setLastUpdateTime(new Date());
borderTop={false} }
borderBottom={false} }, [resultDisplay]);
borderLeft={true}
borderRight={true} useEffect(() => {
paddingX={1} if (isThisShellFocused) {
flexDirection="column" setUserHasFocused(true);
> }
<ToolResultDisplay }, [isThisShellFocused]);
resultDisplay={resultDisplay}
availableTerminalHeight={availableTerminalHeight} const isThisShellFocusable =
terminalWidth={terminalWidth} (name === SHELL_COMMAND_NAME || name === 'Shell') &&
renderOutputAsMarkdown={renderOutputAsMarkdown} status === ToolCallStatus.Executing &&
/> config?.getEnableInteractiveShell();
const shouldShowFocusHint =
isThisShellFocusable && (showFocusHint || userHasFocused);
return (
<Box flexDirection="column" width={terminalWidth}>
<StickyHeader
width={terminalWidth}
isFirst={isFirst}
borderColor={borderColor}
borderDimColor={borderDimColor}
>
<ToolStatusIndicator status={status} name={name} />
<ToolInfo
name={name}
status={status}
description={description}
emphasis={emphasis}
/>
{shouldShowFocusHint && (
<Box marginLeft={1} flexShrink={0}>
<Text color={theme.text.accent}>
{isThisShellFocused ? '(Focused)' : '(ctrl+f to focus)'}
</Text>
</Box>
)}
{emphasis === 'high' && <TrailingIndicator />}
</StickyHeader>
<Box
width={terminalWidth}
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
<ToolResultDisplay
resultDisplay={resultDisplay}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
/>
</Box>
)}
</Box>
</Box> </Box>
</Box> );
); };

View File

@@ -24,6 +24,8 @@ export const SHELL_NAME = 'Shell';
// usage. // usage.
export const MAX_GEMINI_MESSAGE_LINES = 65536; export const MAX_GEMINI_MESSAGE_LINES = 65536;
export const SHELL_FOCUS_HINT_DELAY_MS = 5000;
// Tool status symbols used in ToolMessage component // Tool status symbols used in ToolMessage component
export const TOOL_STATUS = { export const TOOL_STATUS = {
SUCCESS: '✓', SUCCESS: '✓',

View File

@@ -76,6 +76,8 @@ export const useShellCommandProcessor = (
terminalHeight?: number, terminalHeight?: number,
) => { ) => {
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null); const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
const handleShellCommand = useCallback( const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
@@ -202,6 +204,7 @@ export const useShellCommandProcessor = (
// Throttle pending UI updates, but allow forced updates. // Throttle pending UI updates, but allow forced updates.
if (shouldUpdate) { if (shouldUpdate) {
setLastShellOutputTime(Date.now());
setPendingHistoryItem((prevItem) => { setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') { if (prevItem?.type === 'tool_group') {
return { return {
@@ -366,5 +369,5 @@ export const useShellCommandProcessor = (
], ],
); );
return { handleShellCommand, activeShellPtyId }; return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
}; };

View File

@@ -136,6 +136,7 @@ export const useGeminiStream = (
markToolsAsSubmitted, markToolsAsSubmitted,
setToolCallsForDisplay, setToolCallsForDisplay,
cancelAllToolCalls, cancelAllToolCalls,
lastToolOutputTime,
] = useReactToolScheduler( ] = useReactToolScheduler(
async (completedToolCallsFromScheduler) => { async (completedToolCallsFromScheduler) => {
// This onComplete is called when ALL scheduled tools for a given batch are done. // This onComplete is called when ALL scheduled tools for a given batch are done.
@@ -211,17 +212,18 @@ export const useGeminiStream = (
await done; await done;
setIsResponding(false); setIsResponding(false);
}, []); }, []);
const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor( const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
addItem, useShellCommandProcessor(
setPendingHistoryItem, addItem,
onExec, setPendingHistoryItem,
onDebugMessage, onExec,
config, onDebugMessage,
geminiClient, config,
setShellInputFocused, geminiClient,
terminalWidth, setShellInputFocused,
terminalHeight, terminalWidth,
); terminalHeight,
);
const activePtyId = activeShellPtyId || activeToolPtyId; const activePtyId = activeShellPtyId || activeToolPtyId;
@@ -681,8 +683,9 @@ export const useGeminiStream = (
[FinishReason.UNEXPECTED_TOOL_CALL]: [FinishReason.UNEXPECTED_TOOL_CALL]:
'Response stopped due to unexpected tool call.', 'Response stopped due to unexpected tool call.',
[FinishReason.IMAGE_PROHIBITED_CONTENT]: [FinishReason.IMAGE_PROHIBITED_CONTENT]:
'Response stopped due to prohibited content.', 'Response stopped due to prohibited image content.',
[FinishReason.NO_IMAGE]: 'Response stopped due to no image.', [FinishReason.NO_IMAGE]:
'Response stopped because no image was generated.',
}; };
const message = finishReasonMessages[finishReason]; const message = finishReasonMessages[finishReason];
@@ -1348,6 +1351,8 @@ export const useGeminiStream = (
storage, storage,
]); ]);
const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime);
return { return {
streamingState, streamingState,
submitQuery, submitQuery,
@@ -1359,5 +1364,6 @@ export const useGeminiStream = (
handleApprovalModeChange, handleApprovalModeChange,
activePtyId, activePtyId,
loopDetectionConfirmationRequest, loopDetectionConfirmationRequest,
lastOutputTime,
}; };
}; };

View File

@@ -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;
};

View File

@@ -9,8 +9,12 @@ import { act } from 'react';
import { render } from '../../test-utils/render.js'; import { render } from '../../test-utils/render.js';
import { useLoadingIndicator } from './useLoadingIndicator.js'; import { useLoadingIndicator } from './useLoadingIndicator.js';
import { StreamingState } from '../types.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 { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js';
describe('useLoadingIndicator', () => { describe('useLoadingIndicator', () => {
beforeEach(() => { beforeEach(() => {
@@ -25,18 +29,33 @@ describe('useLoadingIndicator', () => {
const renderLoadingIndicatorHook = ( const renderLoadingIndicatorHook = (
initialStreamingState: StreamingState, initialStreamingState: StreamingState,
initialIsInteractiveShellWaiting: boolean = false,
initialLastOutputTime: number = 0,
) => { ) => {
let hookResult: ReturnType<typeof useLoadingIndicator>; let hookResult: ReturnType<typeof useLoadingIndicator>;
function TestComponent({ function TestComponent({
streamingState, streamingState,
isInteractiveShellWaiting,
lastOutputTime,
}: { }: {
streamingState: StreamingState; streamingState: StreamingState;
isInteractiveShellWaiting?: boolean;
lastOutputTime?: number;
}) { }) {
hookResult = useLoadingIndicator(streamingState); hookResult = useLoadingIndicator(
streamingState,
undefined,
isInteractiveShellWaiting,
lastOutputTime,
);
return null; return null;
} }
const { rerender } = render( const { rerender } = render(
<TestComponent streamingState={initialStreamingState} />, <TestComponent
streamingState={initialStreamingState}
isInteractiveShellWaiting={initialIsInteractiveShellWaiting}
lastOutputTime={initialLastOutputTime}
/>,
); );
return { return {
result: { result: {
@@ -44,8 +63,11 @@ describe('useLoadingIndicator', () => {
return hookResult; return hookResult;
}, },
}, },
rerender: (newProps: { streamingState: StreamingState }) => rerender: (newProps: {
rerender(<TestComponent {...newProps} />), streamingState: StreamingState;
isInteractiveShellWaiting?: boolean;
lastOutputTime?: number;
}) => rerender(<TestComponent {...newProps} />),
}; };
}; };
@@ -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 () => { it('should reflect values when Responding', async () => {
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty for subsequent phrases
const { result } = renderLoadingIndicatorHook(StreamingState.Responding); const { result } = renderLoadingIndicatorHook(StreamingState.Responding);

View File

@@ -12,6 +12,8 @@ import { useState, useEffect, useRef } from 'react'; // Added useRef
export const useLoadingIndicator = ( export const useLoadingIndicator = (
streamingState: StreamingState, streamingState: StreamingState,
customWittyPhrases?: string[], customWittyPhrases?: string[],
isInteractiveShellWaiting: boolean = false,
lastOutputTime: number = 0,
) => { ) => {
const [timerResetKey, setTimerResetKey] = useState(0); const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding; const isTimerActive = streamingState === StreamingState.Responding;
@@ -23,6 +25,8 @@ export const useLoadingIndicator = (
const currentLoadingPhrase = usePhraseCycler( const currentLoadingPhrase = usePhraseCycler(
isPhraseCyclingActive, isPhraseCyclingActive,
isWaiting, isWaiting,
isInteractiveShellWaiting,
lastOutputTime,
customWittyPhrases, customWittyPhrases,
); );

View File

@@ -11,6 +11,7 @@ import { Text } from 'ink';
import { import {
usePhraseCycler, usePhraseCycler,
PHRASE_CHANGE_INTERVAL_MS, PHRASE_CHANGE_INTERVAL_MS,
INTERACTIVE_SHELL_WAITING_PHRASE,
} from './usePhraseCycler.js'; } from './usePhraseCycler.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
@@ -19,13 +20,23 @@ import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
const TestComponent = ({ const TestComponent = ({
isActive, isActive,
isWaiting, isWaiting,
isInteractiveShellWaiting = false,
lastOutputTime = 0,
customPhrases, customPhrases,
}: { }: {
isActive: boolean; isActive: boolean;
isWaiting: boolean; isWaiting: boolean;
isInteractiveShellWaiting?: boolean;
lastOutputTime?: number;
customPhrases?: string[]; customPhrases?: string[];
}) => { }) => {
const phrase = usePhraseCycler(isActive, isWaiting, customPhrases); const phrase = usePhraseCycler(
isActive,
isWaiting,
isInteractiveShellWaiting,
lastOutputTime,
customPhrases,
);
return <Text>{phrase}</Text>; return <Text>{phrase}</Text>;
}; };
@@ -57,6 +68,102 @@ describe('usePhraseCycler', () => {
expect(lastFrame()).toBe('Waiting for user confirmation...'); 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(
<TestComponent isActive={true} isWaiting={false} />,
);
rerender(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
lastOutputTime={1}
/>,
);
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(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
lastOutputTime={1000}
/>,
);
// 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(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
lastOutputTime={4000}
/>,
);
// 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(
<TestComponent isActive={true} isWaiting={true} />,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(lastFrame()).toBe('Waiting for user confirmation...');
rerender(
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
lastOutputTime={1}
/>,
);
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 () => { it('should not cycle phrases if isActive is false and not waiting', async () => {
const { lastFrame } = render( const { lastFrame } = render(
<TestComponent isActive={false} isWaiting={false} />, <TestComponent isActive={false} isWaiting={false} />,

View File

@@ -5,20 +5,28 @@
*/ */
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { SHELL_FOCUS_HINT_DELAY_MS } from '../constants.js';
import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js';
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
import { useInactivityTimer } from './useInactivityTimer.js';
export const PHRASE_CHANGE_INTERVAL_MS = 15000; 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. * Custom hook to manage cycling through loading phrases.
* @param isActive Whether the phrase cycling should be active. * @param isActive Whether the phrase cycling should be active.
* @param isWaiting Whether to show a specific waiting phrase. * @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. * @returns The current loading phrase.
*/ */
export const usePhraseCycler = ( export const usePhraseCycler = (
isActive: boolean, isActive: boolean,
isWaiting: boolean, isWaiting: boolean,
isInteractiveShellWaiting: boolean,
lastOutputTime: number = 0,
customPhrases?: string[], customPhrases?: string[],
) => { ) => {
const loadingPhrases = const loadingPhrases =
@@ -29,66 +37,79 @@ export const usePhraseCycler = (
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
loadingPhrases[0], loadingPhrases[0],
); );
const showShellFocusHint = useInactivityTimer(
isInteractiveShellWaiting && lastOutputTime > 0,
lastOutputTime,
SHELL_FOCUS_HINT_DELAY_MS,
);
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null); const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasShownFirstRequestTipRef = useRef(false); const hasShownFirstRequestTipRef = useRef(false);
useEffect(() => { 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) { if (isWaiting) {
setCurrentLoadingPhrase('Waiting for user confirmation...'); setCurrentLoadingPhrase('Waiting for user confirmation...');
if (phraseIntervalRef.current) { return;
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]);
} }
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 () => { return () => {
if (phraseIntervalRef.current) { if (phraseIntervalRef.current) {
clearInterval(phraseIntervalRef.current); clearInterval(phraseIntervalRef.current);
phraseIntervalRef.current = null; phraseIntervalRef.current = null;
} }
}; };
}, [isActive, isWaiting, customPhrases, loadingPhrases]); }, [
isActive,
isWaiting,
isInteractiveShellWaiting,
customPhrases,
loadingPhrases,
showShellFocusHint,
]);
return currentLoadingPhrase; return currentLoadingPhrase;
}; };

View File

@@ -74,10 +74,12 @@ export function useReactToolScheduler(
MarkToolsAsSubmittedFn, MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>, React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn, CancelAllFn,
number,
] { ] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState< const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[] TrackedToolCall[]
>([]); >([]);
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
// Store callbacks in refs to keep them up-to-date without causing re-renders. // Store callbacks in refs to keep them up-to-date without causing re-renders.
const onCompleteRef = useRef(onComplete); const onCompleteRef = useRef(onComplete);
@@ -93,6 +95,7 @@ export function useReactToolScheduler(
const outputUpdateHandler: OutputUpdateHandler = useCallback( const outputUpdateHandler: OutputUpdateHandler = useCallback(
(toolCallId, outputChunk) => { (toolCallId, outputChunk) => {
setLastToolOutputTime(Date.now());
setToolCallsForDisplay((prevCalls) => setToolCallsForDisplay((prevCalls) =>
prevCalls.map((tc) => { prevCalls.map((tc) => {
if (tc.request.callId === toolCallId && tc.status === 'executing') { if (tc.request.callId === toolCallId && tc.status === 'executing') {
@@ -208,6 +211,7 @@ export function useReactToolScheduler(
markToolsAsSubmitted, markToolsAsSubmitted,
setToolCallsForDisplay, setToolCallsForDisplay,
cancelAllToolCalls, cancelAllToolCalls,
lastToolOutputTime,
]; ];
} }