Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
Expand Down Expand Up @@ -1104,6 +1105,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
);

const handleGlobalKeypress = useCallback(
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/ui/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,7 +37,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
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
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/ui/components/messages/ShellToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,7 +108,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({

const timer = setTimeout(() => {
setShowFocusHint(true);
}, 5000);
}, SHELL_FOCUS_HINT_DELAY_MS);

return () => clearTimeout(timer);
}, [lastUpdateTime]);
Expand Down
146 changes: 108 additions & 38 deletions packages/cli/src/ui/components/messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 };

Expand All @@ -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<ToolMessageProps> = ({
Expand All @@ -40,41 +55,96 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
isFirst,
borderColor,
borderDimColor,
}) => (
<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}
/>
{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}
/>
activeShellPtyId,
embeddedShellFocused,
ptyId,
config,
}) => {
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
Comment on lines +63 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to identify a shell tool is incomplete and uses a hardcoded string. It's missing a check for SHELL_TOOL_NAME (the internal tool name) and it hardcodes 'Shell' instead of using the SHELL_NAME constant. This could lead to inconsistent behavior for identifying shell tools.

To fix this, you should import SHELL_NAME from ../../constants.js and SHELL_TOOL_NAME from @google/gemini-cli-core and update the condition.

Suggested change
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === SHELL_NAME || name === SHELL_TOOL_NAME) &&
status === ToolCallStatus.Executing &&
ptyId === activeShellPtyId &&
embeddedShellFocused;


const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(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();
Comment on lines +89 to +92
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Similar to the check for isThisShellFocused, this condition is incomplete for identifying a shell tool. It should also check for SHELL_TOOL_NAME and use the SHELL_NAME constant instead of a hardcoded string to ensure all shell tools are correctly identified as focusable.

Suggested change
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
config?.getEnableInteractiveShell();
const isThisShellFocusable =
(name === SHELL_COMMAND_NAME || name === SHELL_NAME || name === SHELL_TOOL_NAME) &&
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>
);
);
};
2 changes: 2 additions & 0 deletions packages/cli/src/ui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '✓',
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/hooks/shellCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const useShellCommandProcessor = (
terminalHeight?: number,
) => {
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);

const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -366,5 +369,5 @@ export const useShellCommandProcessor = (
],
);

return { handleShellCommand, activeShellPtyId };
return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
};
32 changes: 19 additions & 13 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -1348,6 +1351,8 @@ export const useGeminiStream = (
storage,
]);

const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime);

return {
streamingState,
submitQuery,
Expand All @@ -1359,5 +1364,6 @@ export const useGeminiStream = (
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
};
};
39 changes: 39 additions & 0 deletions packages/cli/src/ui/hooks/useInactivityTimer.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading