/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { CliSpinner } from '../CliSpinner.js'; import { SHELL_COMMAND_NAME, SHELL_NAME, TOOL_STATUS, SHELL_FOCUS_HINT_DELAY_MS, } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; import { type Config, SHELL_TOOL_NAME, isCompletedAskUserTool, type ToolResultDisplay, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { useInactivityTimer } from '../../hooks/useInactivityTimer.js'; import { formatCommand } from '../../key/keybindingUtils.js'; import { Command } from '../../key/keyBindings.js'; export const STATUS_INDICATOR_WIDTH = 3; /** * Returns true if the tool name corresponds to a shell tool. */ export function isShellTool(name: string): boolean { return ( name === SHELL_COMMAND_NAME || name === SHELL_NAME || name === SHELL_TOOL_NAME ); } /** * Returns true if the shell tool call is currently focusable. */ export function isThisShellFocusable( name: string, status: CoreToolCallStatus, config?: Config, ): boolean { return !!( isShellTool(name) && status === CoreToolCallStatus.Executing && config?.getEnableInteractiveShell() ); } /** * Returns true if this specific shell tool call is currently focused. */ export function isThisShellFocused( name: string, status: CoreToolCallStatus, ptyId?: number, activeShellPtyId?: number | null, embeddedShellFocused?: boolean, ): boolean { return !!( isShellTool(name) && status === CoreToolCallStatus.Executing && ptyId === activeShellPtyId && embeddedShellFocused ); } /** * Hook to manage focus hint state. */ export function useFocusHint( isThisShellFocusable: boolean, isThisShellFocused: boolean, resultDisplay: ToolResultDisplay | undefined, ) { const [userHasFocused, setUserHasFocused] = useState(false); // Derive a stable reset key for the inactivity timer. For strings and arrays // (shell output), we use the length to capture updates without referential // identity issues or expensive deep comparisons. const resetKey = typeof resultDisplay === 'string' ? resultDisplay.length : Array.isArray(resultDisplay) ? resultDisplay.length : !!resultDisplay; const showFocusHint = useInactivityTimer( isThisShellFocusable, resetKey, SHELL_FOCUS_HINT_DELAY_MS, ); useEffect(() => { if (isThisShellFocused) { setUserHasFocused(true); } }, [isThisShellFocused]); const shouldShowFocusHint = isThisShellFocusable && (showFocusHint || userHasFocused); return { shouldShowFocusHint }; } /** * Component to render the focus hint. */ export const FocusHint: React.FC<{ shouldShowFocusHint: boolean; isThisShellFocused: boolean; }> = ({ shouldShowFocusHint, isThisShellFocused }) => { if (!shouldShowFocusHint) { return null; } return ( {isThisShellFocused ? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)` : `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`} ); }; export type TextEmphasis = 'high' | 'medium' | 'low'; type ToolStatusIndicatorProps = { status: CoreToolCallStatus; name: string; isFocused?: boolean; }; export const ToolStatusIndicator: React.FC = ({ status: coreStatus, name, isFocused, }) => { const status = mapCoreStatusToDisplayStatus(coreStatus); const isShell = isShellTool(name); const statusColor = isFocused ? theme.ui.focus : isShell ? theme.ui.active : theme.status.warning; return ( {status === ToolCallStatus.Pending && ( {TOOL_STATUS.PENDING} )} {status === ToolCallStatus.Executing && ( )} {status === ToolCallStatus.Success && ( {TOOL_STATUS.SUCCESS} )} {status === ToolCallStatus.Confirming && ( {TOOL_STATUS.CONFIRMING} )} {status === ToolCallStatus.Canceled && ( {TOOL_STATUS.CANCELED} )} {status === ToolCallStatus.Error && ( {TOOL_STATUS.ERROR} )} ); }; type ToolInfoProps = { name: string; description: string; status: CoreToolCallStatus; emphasis: TextEmphasis; progressMessage?: string; originalRequestName?: string; }; export const ToolInfo: React.FC = ({ name, description, status: coreStatus, emphasis, progressMessage: _progressMessage, originalRequestName, }) => { const status = mapCoreStatusToDisplayStatus(coreStatus); const nameColor = React.useMemo(() => { switch (emphasis) { case 'high': return theme.text.primary; case 'medium': return theme.text.primary; case 'low': return theme.text.secondary; default: { const exhaustiveCheck: never = emphasis; return exhaustiveCheck; } } }, [emphasis]); // Hide description for completed Ask User tools (the result display speaks for itself) const isCompletedAskUser = isCompletedAskUserTool(name, status); return ( {name} {originalRequestName && originalRequestName !== name && ( {' '} (redirection from {originalRequestName}) )} {!isCompletedAskUser && ( <> {' '} {description} )} ); }; export interface McpProgressIndicatorProps { progress: number; total?: number; message?: string; barWidth: number; } export const McpProgressIndicator: React.FC = ({ progress, total, message, barWidth, }) => { const percentage = total && total > 0 ? Math.min(100, Math.round((progress / total) * 100)) : null; let rawFilled: number; if (total && total > 0) { rawFilled = Math.round((progress / total) * barWidth); } else { rawFilled = Math.floor(progress) % (barWidth + 1); } const filled = Math.max( 0, Math.min(Number.isFinite(rawFilled) ? rawFilled : 0, barWidth), ); const empty = Math.max(0, barWidth - filled); const progressBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty); return ( {progressBar} {percentage !== null ? `${percentage}%` : `${progress}`} {message && ( {message} )} ); }; export const TrailingIndicator: React.FC = () => ( {' '} ← );