mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 07:01:09 -07:00
feat: custom loading phrase when interactive shell requires input (#12535)
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -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: '✓',
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
39
packages/cli/src/ui/hooks/useInactivityTimer.ts
Normal file
39
packages/cli/src/ui/hooks/useInactivityTimer.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user