diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 76398b7b55..9eaabbb4fc 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -5,17 +5,9 @@ */ import React from 'react'; -import { Box, Text, type DOMElement } from 'ink'; -import { ToolCallStatus } from '../../types.js'; +import { Box, type DOMElement } from 'ink'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { StickyHeader } from '../StickyHeader.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'; import { useMouseClick } from '../../hooks/useMouseClick.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; @@ -24,6 +16,10 @@ import { ToolInfo, TrailingIndicator, STATUS_INDICATOR_WIDTH, + isThisShellFocusable as checkIsShellFocusable, + isThisShellFocused as checkIsShellFocused, + useFocusHint, + FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; import type { Config } from '@google/gemini-cli-core'; @@ -65,13 +61,13 @@ export const ShellToolMessage: React.FC = ({ borderDimColor, }) => { - const isThisShellFocused = - (name === SHELL_COMMAND_NAME || - name === SHELL_NAME || - name === SHELL_TOOL_NAME) && - status === ToolCallStatus.Executing && - ptyId === activeShellPtyId && - embeddedShellFocused; + const isThisShellFocused = checkIsShellFocused( + name, + status, + ptyId, + activeShellPtyId, + embeddedShellFocused, + ); const { setEmbeddedShellFocused } = useUIActions(); @@ -81,12 +77,7 @@ export const ShellToolMessage: React.FC = ({ // The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled. - const isThisShellFocusable = - (name === SHELL_COMMAND_NAME || - name === SHELL_NAME || - name === SHELL_TOOL_NAME) && - status === ToolCallStatus.Executing && - config?.getEnableInteractiveShell(); + const isThisShellFocusable = checkIsShellFocusable(name, status, config); const handleFocus = () => { if (isThisShellFocusable) { @@ -112,38 +103,11 @@ export const ShellToolMessage: React.FC = ({ } }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); - const [lastUpdateTime, setLastUpdateTime] = React.useState(null); - - const [userHasFocused, setUserHasFocused] = React.useState(false); - - const [showFocusHint, setShowFocusHint] = React.useState(false); - - React.useEffect(() => { - if (resultDisplay) { - setLastUpdateTime(new Date()); - } - }, [resultDisplay]); - - React.useEffect(() => { - if (!lastUpdateTime) { - return; - } - - const timer = setTimeout(() => { - setShowFocusHint(true); - }, SHELL_FOCUS_HINT_DELAY_MS); - - return () => clearTimeout(timer); - }, [lastUpdateTime]); - - React.useEffect(() => { - if (isThisShellFocused) { - setUserHasFocused(true); - } - }, [isThisShellFocused]); - - const shouldShowFocusHint = - isThisShellFocusable && (showFocusHint || userHasFocused); + const { shouldShowFocusHint } = useFocusHint( + isThisShellFocusable, + isThisShellFocused, + resultDisplay, + ); return ( <> @@ -163,13 +127,10 @@ export const ShellToolMessage: React.FC = ({ emphasis={emphasis} /> - {shouldShowFocusHint && ( - - - {isThisShellFocused ? '(Focused)' : '(tab to focus)'} - - - )} + {emphasis === 'high' && } diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index dda785b906..ac6f36ad60 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -13,9 +13,8 @@ import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { theme } from '../../semantic-colors.js'; -import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; -import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { useConfig } from '../../contexts/ConfigContext.js'; +import { isShellTool, isThisShellFocused } from './ToolShared.js'; interface ToolGroupMessageProps { groupId: number; @@ -37,21 +36,22 @@ export const ToolGroupMessage: React.FC = ({ activeShellPtyId, embeddedShellFocused, }) => { - const isEmbeddedShellFocused = - embeddedShellFocused && - toolCalls.some( - (t) => - t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, - ); + const isEmbeddedShellFocused = toolCalls.some((t) => + isThisShellFocused( + t.name, + t.status, + t.ptyId, + activeShellPtyId, + embeddedShellFocused, + ), + ); const hasPending = !toolCalls.every( (t) => t.status === ToolCallStatus.Success, ); const config = useConfig(); - const isShellCommand = toolCalls.some( - (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, - ); + const isShellCommand = toolCalls.some((t) => isShellTool(t.name)); const borderColor = (isShellCommand && hasPending) || isEmbeddedShellFocused ? theme.ui.symbol @@ -105,10 +105,7 @@ export const ToolGroupMessage: React.FC = ({ {toolCalls.map((tool, index) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; const isFirst = index === 0; - const isShellTool = - tool.name === SHELL_COMMAND_NAME || - tool.name === SHELL_NAME || - tool.name === SHELL_TOOL_NAME; + const isShellToolCall = isShellTool(tool.name); const commonProps = { ...tool, @@ -131,7 +128,7 @@ export const ToolGroupMessage: React.FC = ({ minHeight={1} width={terminalWidth} > - {isShellTool ? ( + {isShellToolCall ? ( = ({ 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, + const isThisShellFocused = checkIsShellFocused( + name, + status, + ptyId, + activeShellPtyId, + embeddedShellFocused, ); - useEffect(() => { - if (resultDisplay) { - setLastUpdateTime(new Date()); - } - }, [resultDisplay]); + const isThisShellFocusable = checkIsShellFocusable(name, status, config); - useEffect(() => { - if (isThisShellFocused) { - setUserHasFocused(true); - } - }, [isThisShellFocused]); - - const isThisShellFocusable = - (name === SHELL_COMMAND_NAME || name === 'Shell') && - status === ToolCallStatus.Executing && - config?.getEnableInteractiveShell(); - - const shouldShowFocusHint = - isThisShellFocusable && (showFocusHint || userHasFocused); + const { shouldShowFocusHint } = useFocusHint( + isThisShellFocusable, + isThisShellFocused, + resultDisplay, + ); return ( // It is crucial we don't replace this <> with a Box because otherwise the @@ -112,13 +90,10 @@ export const ToolMessage: React.FC = ({ description={description} emphasis={emphasis} /> - {shouldShowFocusHint && ( - - - {isThisShellFocused ? '(Focused)' : '(tab to focus)'} - - - )} + {emphasis === 'high' && } ({ + GeminiRespondingSpinner: () => null, +})); + +vi.mock('./ToolResultDisplay.js', () => ({ + ToolResultDisplay: () => null, +})); + +describe('Focus Hint', () => { + const mockConfig = { + getEnableInteractiveShell: () => true, + } as Config; + + const baseProps = { + callId: 'tool-123', + name: SHELL_COMMAND_NAME, + description: 'A tool for testing', + resultDisplay: undefined as ToolResultDisplay | undefined, + status: ToolCallStatus.Executing, + terminalWidth: 80, + confirmationDetails: undefined, + emphasis: 'medium' as const, + isFirst: true, + borderColor: 'green', + borderDimColor: false, + config: mockConfig, + ptyId: 1, + activeShellPtyId: 1, + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + const testCases = [ + { Component: ToolMessage, componentName: 'ToolMessage' }, + { Component: ShellToolMessage, componentName: 'ShellToolMessage' }, + ]; + + describe.each(testCases)('$componentName', ({ Component }) => { + it('shows focus hint after delay even with NO output', async () => { + const { lastFrame } = renderWithProviders( + , + { uiState: { streamingState: StreamingState.Idle } }, + ); + + // Initially, no focus hint + expect(lastFrame()).toMatchSnapshot('initial-no-output'); + + // Advance timers by the delay + act(() => { + vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100); + }); + + // Now it SHOULD contain the focus hint + expect(lastFrame()).toMatchSnapshot('after-delay-no-output'); + expect(lastFrame()).toContain('(tab to focus)'); + }); + + it('shows focus hint after delay with output', async () => { + const { lastFrame } = renderWithProviders( + , + { uiState: { streamingState: StreamingState.Idle } }, + ); + + // Initially, no focus hint + expect(lastFrame()).toMatchSnapshot('initial-with-output'); + + // Advance timers + act(() => { + vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100); + }); + + expect(lastFrame()).toMatchSnapshot('after-delay-with-output'); + expect(lastFrame()).toContain('(tab to focus)'); + }); + }); + + it('handles long descriptions by shrinking them to show the focus hint', async () => { + const longDescription = 'A'.repeat(100); + const { lastFrame } = renderWithProviders( + , + { uiState: { streamingState: StreamingState.Idle } }, + ); + + act(() => { + vi.advanceTimersByTime(SHELL_FOCUS_HINT_DELAY_MS + 100); + }); + + // The focus hint should be visible + expect(lastFrame()).toMatchSnapshot('long-description'); + expect(lastFrame()).toContain('(tab to focus)'); + // The name should still be visible + expect(lastFrame()).toContain(SHELL_COMMAND_NAME); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx index df567ddb3f..ccd38f6f77 100644 --- a/packages/cli/src/ui/components/messages/ToolShared.tsx +++ b/packages/cli/src/ui/components/messages/ToolShared.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { ToolCallStatus } from '../../types.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; @@ -12,12 +12,116 @@ import { SHELL_COMMAND_NAME, SHELL_NAME, TOOL_STATUS, + SHELL_FOCUS_HINT_DELAY_MS, } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; -import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; +import { + type Config, + SHELL_TOOL_NAME, + type ToolResultDisplay, +} from '@google/gemini-cli-core'; +import { useInactivityTimer } from '../../hooks/useInactivityTimer.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: ToolCallStatus, + config?: Config, +): boolean { + return !!( + isShellTool(name) && + status === ToolCallStatus.Executing && + config?.getEnableInteractiveShell() + ); +} + +/** + * Returns true if this specific shell tool call is currently focused. + */ +export function isThisShellFocused( + name: string, + status: ToolCallStatus, + ptyId?: number, + activeShellPtyId?: number | null, + embeddedShellFocused?: boolean, +): boolean { + return !!( + isShellTool(name) && + status === ToolCallStatus.Executing && + ptyId === activeShellPtyId && + embeddedShellFocused + ); +} + +/** + * Hook to manage focus hint state. + */ +export function useFocusHint( + isThisShellFocusable: boolean, + isThisShellFocused: boolean, + resultDisplay: ToolResultDisplay | undefined, +) { + const [lastUpdateTime, setLastUpdateTime] = useState(null); + const [userHasFocused, setUserHasFocused] = useState(false); + const showFocusHint = useInactivityTimer( + isThisShellFocusable, + lastUpdateTime ? lastUpdateTime.getTime() : 0, + SHELL_FOCUS_HINT_DELAY_MS, + ); + + useEffect(() => { + if (resultDisplay) { + setLastUpdateTime(new Date()); + } + }, [resultDisplay]); + + 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 ? '(Focused)' : '(tab to focus)'} + + + ); +}; + export type TextEmphasis = 'high' | 'medium' | 'low'; type ToolStatusIndicatorProps = { @@ -29,10 +133,7 @@ export const ToolStatusIndicator: React.FC = ({ status, name, }) => { - const isShell = - name === SHELL_COMMAND_NAME || - name === SHELL_NAME || - name === SHELL_TOOL_NAME; + const isShell = isShellTool(name); const statusColor = isShell ? theme.ui.symbol : theme.status.warning; return ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap new file mode 100644 index 0000000000..92ca92bedb --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap @@ -0,0 +1,55 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing (tab to focus) │ +│ │" +`; + +exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing │ +│ │" +`; + +exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing (tab to focus) │ +│ │" +`; + +exports[`Focus Hint > 'ShellToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing │ +│ │" +`; + +exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > after-delay-no-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing (tab to focus) │ +│ │" +`; + +exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay even with NO output > initial-no-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing │ +│ │" +`; + +exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > after-delay-with-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing (tab to focus) │ +│ │" +`; + +exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output > initial-with-output 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command A tool for testing │ +│ │" +`; + +exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… (tab to focus) │ +│ │" +`;