diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 14bb41b8c0..783872a837 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -728,6 +728,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) { const windowTitle = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, + isSilentWorking: false, folderName: title, showThoughts: !!settings.merged.ui.showStatusInTitle, useDynamicTitle: settings.merged.ui.dynamicWindowTitle, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0e1db23583..7223e8c96b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -20,6 +20,7 @@ import { cleanup } from 'ink-testing-library'; import { act, useContext, type ReactElement } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; +import { type TrackedToolCall } from './hooks/useReactToolScheduler.js'; import { type Config, makeFakeConfig, @@ -1274,8 +1275,12 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, cancelOngoingRequest: vi.fn(), + pendingToolCalls: [], + handleApprovalModeChange: vi.fn(), activePtyId: 'pty-1', - lastOutputTime: 0, + loopDetectionConfirmationRequest: null, + lastOutputTime: startTime + 100, // Trigger aggressive delay + retryStatus: null, }); vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); @@ -1309,6 +1314,136 @@ describe('AppContainer State Management', () => { unmount(); }); + it('should show Working… in title for redirected commands after 2 mins', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty with redirection active + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: 'Executing shell command' }, + cancelOngoingRequest: vi.fn(), + pendingToolCalls: [ + { + request: { + name: 'run_shell_command', + args: { command: 'ls > out' }, + }, + status: 'executing', + } as unknown as TrackedToolCall, + ], + handleApprovalModeChange: vi.fn(), + activePtyId: 'pty-1', + loopDetectionConfirmationRequest: null, + lastOutputTime: startTime, + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Fast-forward time by 65 seconds - should still NOT be Action Required + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWritesMid = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain( + '✋ Action Required', + ); + + // Fast-forward to 2 minutes (120000ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + + const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain( + '⏲ Working…', + ); + + unmount(); + }); + + it('should show Working… in title for silent non-redirected commands after 1 min', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Arrange: Set up mock settings with showStatusInTitle enabled + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, + }, + } as unknown as LoadedSettings; + + // Mock an active shell pty with NO output since operation started (silent) + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: 'Executing shell command' }, + cancelOngoingRequest: vi.fn(), + pendingToolCalls: [], + handleApprovalModeChange: vi.fn(), + activePtyId: 'pty-1', + loopDetectionConfirmationRequest: null, + lastOutputTime: startTime, // lastOutputTime <= operationStartTime + retryStatus: null, + }); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); + + // Fast-forward time by 65 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(65000); + }); + + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWrites[titleWrites.length - 1][0]; + // Should show Working… (⏲) instead of Action Required (✋) + expect(lastTitle).toContain('⏲ Working…'); + + unmount(); + }); + it('should NOT show Action Required in title if shell is streaming output', async () => { const startTime = 1000000; vi.setSystemTime(startTime); @@ -1327,7 +1462,7 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Mock an active shell pty but not focused - let lastOutputTime = 1000; + let lastOutputTime = startTime + 1000; mockedUseGeminiStream.mockImplementation(() => ({ streamingState: 'responding', submitQuery: vi.fn(), @@ -1353,7 +1488,7 @@ describe('AppContainer State Management', () => { }); // Update lastOutputTime to simulate new output - lastOutputTime = 21000; + lastOutputTime = startTime + 21000; mockedUseGeminiStream.mockImplementation(() => ({ streamingState: 'responding', submitQuery: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c1f322ee1d..5b9dd02f40 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -94,6 +94,7 @@ import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { keyMatchers, Command } from './keyMatchers.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; +import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; @@ -127,10 +128,8 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, - SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; -import { useInactivityTimer } from './hooks/useInactivityTimer.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -814,6 +813,7 @@ Logging in with Google... Restarting Gemini CLI to continue. pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + pendingToolCalls, handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, @@ -845,15 +845,17 @@ Logging in with Google... Restarting Gemini CLI to continue. lastOutputTimeRef.current = lastOutputTime; }, [lastOutputTime]); - const isShellAwaitingFocus = - !!activePtyId && - !embeddedShellFocused && - config.isInteractiveShellEnabled(); - const showShellActionRequired = useInactivityTimer( - isShellAwaitingFocus, + const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({ + activePtyId, lastOutputTime, - SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, - ); + streamingState, + pendingToolCalls, + embeddedShellFocused, + isInteractiveShellEnabled: config.isInteractiveShellEnabled(), + }); + + const shouldShowActionRequiredTitle = inactivityStatus === 'action_required'; + const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working'; // Auto-accept indicator const showApprovalModeIndicator = useApprovalModeIndicator({ @@ -1235,13 +1237,11 @@ Logging in with Google... Restarting Gemini CLI to continue. [handleSlashCommand, settings], ); - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( + const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({ streamingState, - settings.merged.ui.customWittyPhrases, - !!activePtyId && !embeddedShellFocused, - lastOutputTime, + shouldShowFocusHint, retryStatus, - ); + }); const handleGlobalKeypress = useCallback( (key: Key) => { @@ -1371,7 +1371,8 @@ Logging in with Google... Restarting Gemini CLI to continue. const paddedTitle = computeTerminalTitle({ streamingState, thoughtSubject: thought?.subject, - isConfirming: !!confirmationRequest || showShellActionRequired, + isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle, + isSilentWorking: shouldShowSilentWorkingTitle, folderName: basename(config.getTargetDir()), showThoughts: !!settings.merged.ui.showStatusInTitle, useDynamicTitle: settings.merged.ui.dynamicWindowTitle, @@ -1387,7 +1388,8 @@ Logging in with Google... Restarting Gemini CLI to continue. streamingState, thought, confirmationRequest, - showShellActionRequired, + shouldShowActionRequiredTitle, + shouldShowSilentWorkingTitle, settings.merged.ui.showStatusInTitle, settings.merged.ui.dynamicWindowTitle, settings.merged.ui.hideWindowTitle, diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx index 7dc5341331..a1159d4658 100644 --- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx +++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx @@ -44,12 +44,6 @@ describe('ToolConfirmationMessage Redirection', () => { ); const output = lastFrame(); - expect(output).toContain('echo "hello" > test.txt'); - expect(output).toContain( - 'Note: Command contains redirection which can be undesirable.', - ); - expect(output).toContain( - 'Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.', - ); + expect(output).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap new file mode 100644 index 0000000000..08648abdde --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/RedirectionConfirmation.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ToolConfirmationMessage Redirection > should display redirection warning and tip for redirected commands 1`] = ` +"echo "hello" > test.txt + +Note: Command contains redirection which can be undesirable. +Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future. + +Allow execution of: 'echo, redirection (>)'? + +● 1. Allow once + 2. Allow for this session + 3. No, suggest changes (esc) +" +`; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 681a33a71b..75f1770837 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -32,6 +32,7 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10; export const WARNING_PROMPT_DURATION_MS = 1000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; +export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; export const KEYBOARD_SHORTCUTS_URL = 'https://geminicli.com/docs/cli/keyboard-shortcuts/'; diff --git a/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap new file mode 100644 index 0000000000..77d028caa7 --- /dev/null +++ b/packages/cli/src/ui/hooks/__snapshots__/usePhraseCycler.test.tsx.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`; + +exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`; + +exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`; + +exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`; + +exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`; diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx index e1c17dd98d..df0a766fae 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx @@ -31,36 +31,30 @@ describe('useLoadingIndicator', () => { const renderLoadingIndicatorHook = ( initialStreamingState: StreamingState, - initialIsInteractiveShellWaiting: boolean = false, - initialLastOutputTime: number = 0, + initialShouldShowFocusHint: boolean = false, initialRetryStatus: RetryAttemptPayload | null = null, ) => { let hookResult: ReturnType; function TestComponent({ streamingState, - isInteractiveShellWaiting, - lastOutputTime, + shouldShowFocusHint, retryStatus, }: { streamingState: StreamingState; - isInteractiveShellWaiting?: boolean; - lastOutputTime?: number; + shouldShowFocusHint?: boolean; retryStatus?: RetryAttemptPayload | null; }) { - hookResult = useLoadingIndicator( + hookResult = useLoadingIndicator({ streamingState, - undefined, - isInteractiveShellWaiting, - lastOutputTime, - retryStatus, - ); + shouldShowFocusHint: !!shouldShowFocusHint, + retryStatus: retryStatus || null, + }); return null; } const { rerender } = render( , ); @@ -72,8 +66,7 @@ describe('useLoadingIndicator', () => { }, rerender: (newProps: { streamingState: StreamingState; - isInteractiveShellWaiting?: boolean; - lastOutputTime?: number; + shouldShowFocusHint?: boolean; retryStatus?: RetryAttemptPayload | null; }) => rerender(), }; @@ -88,12 +81,11 @@ describe('useLoadingIndicator', () => { ); }); - it('should show interactive shell waiting phrase when isInteractiveShellWaiting is true after 5s', async () => { + it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => { vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderLoadingIndicatorHook( + const { result, rerender } = renderLoadingIndicatorHook( StreamingState.Responding, - true, - 1, + false, ); // Initially should be witty phrase or tip @@ -102,7 +94,10 @@ describe('useLoadingIndicator', () => { ); await act(async () => { - await vi.advanceTimersByTimeAsync(5000); + rerender({ + streamingState: StreamingState.Responding, + shouldShowFocusHint: true, + }); }); expect(result.current.currentLoadingPhrase).toBe( @@ -224,12 +219,10 @@ describe('useLoadingIndicator', () => { const { result } = renderLoadingIndicatorHook( StreamingState.Responding, false, - 0, retryStatus, ); - expect(result.current.currentLoadingPhrase).toBe( - 'Trying to reach gemini-pro (Retry 2/2)', - ); + expect(result.current.currentLoadingPhrase).toContain('Trying to reach'); + expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3'); }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index 984acc3356..2c8f6b0d1e 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -13,13 +13,19 @@ import { type RetryAttemptPayload, } from '@google/gemini-cli-core'; -export const useLoadingIndicator = ( - streamingState: StreamingState, - customWittyPhrases?: string[], - isInteractiveShellWaiting: boolean = false, - lastOutputTime: number = 0, - retryStatus: RetryAttemptPayload | null = null, -) => { +export interface UseLoadingIndicatorProps { + streamingState: StreamingState; + shouldShowFocusHint: boolean; + retryStatus: RetryAttemptPayload | null; + customWittyPhrases?: string[]; +} + +export const useLoadingIndicator = ({ + streamingState, + shouldShowFocusHint, + retryStatus, + customWittyPhrases, +}: UseLoadingIndicatorProps) => { const [timerResetKey, setTimerResetKey] = useState(0); const isTimerActive = streamingState === StreamingState.Responding; @@ -30,8 +36,7 @@ export const useLoadingIndicator = ( const currentLoadingPhrase = usePhraseCycler( isPhraseCyclingActive, isWaiting, - isInteractiveShellWaiting, - lastOutputTime, + shouldShowFocusHint, customWittyPhrases, ); @@ -61,7 +66,7 @@ export const useLoadingIndicator = ( }, [streamingState, elapsedTimeFromTimer]); const retryPhrase = retryStatus - ? `Trying to reach ${getDisplayString(retryStatus.model)} (Retry ${retryStatus.attempt}/${retryStatus.maxAttempts - 1})` + ? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})` : null; return { diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index cefa800afd..40b47664d1 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -11,7 +11,6 @@ import { Text } from 'ink'; import { usePhraseCycler, PHRASE_CHANGE_INTERVAL_MS, - INTERACTIVE_SHELL_WAITING_PHRASE, } from './usePhraseCycler.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; @@ -21,20 +20,17 @@ const TestComponent = ({ isActive, isWaiting, isInteractiveShellWaiting = false, - lastOutputTime = 0, customPhrases, }: { isActive: boolean; isWaiting: boolean; isInteractiveShellWaiting?: boolean; - lastOutputTime?: number; customPhrases?: string[]; }) => { const phrase = usePhraseCycler( isActive, isWaiting, isInteractiveShellWaiting, - lastOutputTime, customPhrases, ); return {phrase}; @@ -65,11 +61,10 @@ describe('usePhraseCycler', () => { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - expect(lastFrame()).toBe('Waiting for user confirmation...'); + expect(lastFrame()).toMatchSnapshot(); }); - it('should show interactive shell waiting message when isInteractiveShellWaiting is true after 5s', async () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => { const { lastFrame, rerender } = render( , ); @@ -78,90 +73,34 @@ describe('usePhraseCycler', () => { 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); + expect(lastFrame()).toMatchSnapshot(); }); - it('should reset interactive shell waiting timer when lastOutputTime changes', async () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { lastFrame, rerender } = render( - , - ); - - // 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( - , - ); - - // 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 () => { + it('should prioritize interactive shell waiting over normal waiting immediately', async () => { const { lastFrame, rerender } = render( , ); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - expect(lastFrame()).toBe('Waiting for user confirmation...'); + expect(lastFrame()).toMatchSnapshot(); rerender( , ); await act(async () => { - await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(0); }); - expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE); + expect(lastFrame()).toMatchSnapshot(); }); it('should not cycle phrases if isActive is false and not waiting', async () => { @@ -380,7 +319,7 @@ describe('usePhraseCycler', () => { await act(async () => { await vi.advanceTimersByTimeAsync(0); }); - expect(lastFrame()).toBe('Waiting for user confirmation...'); + expect(lastFrame()).toMatchSnapshot(); // Go back to active cycling - should pick a phrase based on the logic (witty due to mock) rerender(); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 559aaa4a40..4c6e9e706d 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -5,10 +5,8 @@ */ import { useState, useEffect, useRef } from 'react'; -import { SHELL_FOCUS_HINT_DELAY_MS } from '../constants.js'; import { INFORMATIVE_TIPS } from '../constants/tips.js'; import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js'; -import { useInactivityTimer } from './useInactivityTimer.js'; export const PHRASE_CHANGE_INTERVAL_MS = 15000; export const INTERACTIVE_SHELL_WAITING_PHRASE = @@ -18,15 +16,14 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE = * Custom hook to manage cycling through loading phrases. * @param isActive Whether the phrase cycling should be active. * @param isWaiting Whether to show a specific waiting phrase. - * @param isInteractiveShellWaiting Whether an interactive shell is waiting for input but not focused. + * @param shouldShowFocusHint Whether to show the shell focus hint. * @param customPhrases Optional list of custom phrases to use. * @returns The current loading phrase. */ export const usePhraseCycler = ( isActive: boolean, isWaiting: boolean, - isInteractiveShellWaiting: boolean, - lastOutputTime: number = 0, + shouldShowFocusHint: boolean, customPhrases?: string[], ) => { const loadingPhrases = @@ -37,11 +34,7 @@ export const usePhraseCycler = ( const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( loadingPhrases[0], ); - const showShellFocusHint = useInactivityTimer( - isInteractiveShellWaiting, - lastOutputTime, - SHELL_FOCUS_HINT_DELAY_MS, - ); + const phraseIntervalRef = useRef(null); const hasShownFirstRequestTipRef = useRef(false); @@ -52,7 +45,7 @@ export const usePhraseCycler = ( phraseIntervalRef.current = null; } - if (isInteractiveShellWaiting && showShellFocusHint) { + if (shouldShowFocusHint) { setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE); return; } @@ -102,14 +95,7 @@ export const usePhraseCycler = ( phraseIntervalRef.current = null; } }; - }, [ - isActive, - isWaiting, - isInteractiveShellWaiting, - customPhrases, - loadingPhrases, - showShellFocusHint, - ]); + }, [isActive, isWaiting, shouldShowFocusHint, customPhrases, loadingPhrases]); return currentLoadingPhrase; }; diff --git a/packages/cli/src/ui/hooks/useShellInactivityStatus.test.ts b/packages/cli/src/ui/hooks/useShellInactivityStatus.test.ts new file mode 100644 index 0000000000..618091494a --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellInactivityStatus.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useShellInactivityStatus } from './useShellInactivityStatus.js'; +import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; +import { StreamingState } from '../types.js'; + +vi.mock('./useTurnActivityMonitor.js', () => ({ + useTurnActivityMonitor: vi.fn(), +})); + +describe('useShellInactivityStatus', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(useTurnActivityMonitor).mockReturnValue({ + operationStartTime: 1000, + isRedirectionActive: false, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + const defaultProps = { + activePtyId: 'pty-1', + lastOutputTime: 1001, + streamingState: StreamingState.Responding, + pendingToolCalls: [], + embeddedShellFocused: false, + isInteractiveShellEnabled: true, + }; + + it('should show action_required status after 30s when output has been produced', async () => { + const { result } = renderHook(() => useShellInactivityStatus(defaultProps)); + + expect(result.current.inactivityStatus).toBe('none'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(result.current.inactivityStatus).toBe('action_required'); + }); + + it('should show silent_working status after 60s when no output has been produced (silent)', async () => { + const { result } = renderHook(() => + useShellInactivityStatus({ ...defaultProps, lastOutputTime: 500 }), + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(result.current.inactivityStatus).toBe('none'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + expect(result.current.inactivityStatus).toBe('silent_working'); + }); + + it('should show silent_working status after 2 mins for redirected commands', async () => { + vi.mocked(useTurnActivityMonitor).mockReturnValue({ + operationStartTime: 1000, + isRedirectionActive: true, + }); + + const { result } = renderHook(() => useShellInactivityStatus(defaultProps)); + + // Should NOT show action_required even after 60s + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(result.current.inactivityStatus).toBe('none'); + + // Should show silent_working after 2 mins (120000ms) + await act(async () => { + await vi.advanceTimersByTimeAsync(60000); + }); + expect(result.current.inactivityStatus).toBe('silent_working'); + }); + + it('should suppress focus hint when redirected', async () => { + vi.mocked(useTurnActivityMonitor).mockReturnValue({ + operationStartTime: 1000, + isRedirectionActive: true, + }); + + const { result } = renderHook(() => useShellInactivityStatus(defaultProps)); + + // Even after delay, focus hint should be suppressed + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + expect(result.current.shouldShowFocusHint).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/useShellInactivityStatus.ts b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts new file mode 100644 index 0000000000..d0e5c0706d --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellInactivityStatus.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useInactivityTimer } from './useInactivityTimer.js'; +import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; +import { + SHELL_FOCUS_HINT_DELAY_MS, + SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, + SHELL_SILENT_WORKING_TITLE_DELAY_MS, +} from '../constants.js'; +import type { StreamingState } from '../types.js'; +import { type TrackedToolCall } from './useReactToolScheduler.js'; + +interface ShellInactivityStatusProps { + activePtyId: number | string | null | undefined; + lastOutputTime: number; + streamingState: StreamingState; + pendingToolCalls: TrackedToolCall[]; + embeddedShellFocused: boolean; + isInteractiveShellEnabled: boolean; +} + +export type InactivityStatus = 'none' | 'action_required' | 'silent_working'; + +export interface ShellInactivityStatus { + shouldShowFocusHint: boolean; + inactivityStatus: InactivityStatus; +} + +/** + * Consolidated hook to manage all shell-related inactivity states. + * Centralizes the timing heuristics and redirection suppression logic. + */ +export const useShellInactivityStatus = ({ + activePtyId, + lastOutputTime, + streamingState, + pendingToolCalls, + embeddedShellFocused, + isInteractiveShellEnabled, +}: ShellInactivityStatusProps): ShellInactivityStatus => { + const { operationStartTime, isRedirectionActive } = useTurnActivityMonitor( + streamingState, + activePtyId, + pendingToolCalls, + ); + + const isAwaitingFocus = + !!activePtyId && !embeddedShellFocused && isInteractiveShellEnabled; + + // Derive whether output was produced by comparing the last output time to when the operation started. + const hasProducedOutput = lastOutputTime > operationStartTime; + + // 1. Focus Hint (The "press tab to focus" message in the loading indicator) + // Logic: 5s if output has been produced, 20s if silent. Suppressed if redirected. + const shouldShowFocusHint = useInactivityTimer( + isAwaitingFocus && !isRedirectionActive, + lastOutputTime, + hasProducedOutput + ? SHELL_FOCUS_HINT_DELAY_MS + : SHELL_FOCUS_HINT_DELAY_MS * 4, + ); + + // 2. Action Required Status (The ✋ icon in the terminal window title) + // Logic: Only if output has been produced (likely a prompt). + // Triggered after 30s of silence, but SUPPRESSED if redirection is active. + const shouldShowActionRequiredTitle = useInactivityTimer( + isAwaitingFocus && !isRedirectionActive && hasProducedOutput, + lastOutputTime, + SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, + ); + + // 3. Silent Working Status (The ⏲ icon in the terminal window title) + // Logic: If redirected OR if no output has been produced yet (e.g. sleep 600). + // Triggered after 2 mins for redirected, or 60s for non-redirected silent commands. + const shouldShowSilentWorkingTitle = useInactivityTimer( + isAwaitingFocus && (isRedirectionActive || !hasProducedOutput), + lastOutputTime, + isRedirectionActive + ? SHELL_SILENT_WORKING_TITLE_DELAY_MS + : SHELL_ACTION_REQUIRED_TITLE_DELAY_MS * 2, + ); + + let inactivityStatus: InactivityStatus = 'none'; + if (shouldShowActionRequiredTitle) { + inactivityStatus = 'action_required'; + } else if (shouldShowSilentWorkingTitle) { + inactivityStatus = 'silent_working'; + } + + return { + shouldShowFocusHint, + inactivityStatus, + }; +}; diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts new file mode 100644 index 0000000000..9ac44c3ebc --- /dev/null +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { useTurnActivityMonitor } from './useTurnActivityMonitor.js'; +import { StreamingState } from '../types.js'; +import { hasRedirection } from '@google/gemini-cli-core'; +import { type TrackedToolCall } from './useReactToolScheduler.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + hasRedirection: vi.fn(), + }; +}); + +describe('useTurnActivityMonitor', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + vi.mocked(hasRedirection).mockImplementation( + (query: string) => query.includes('>') || query.includes('>>'), + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('should set operationStartTime when entering Responding state', () => { + const { result, rerender } = renderHook( + ({ state }) => useTurnActivityMonitor(state, null, []), + { + initialProps: { state: StreamingState.Idle }, + }, + ); + + expect(result.current.operationStartTime).toBe(0); + + rerender({ state: StreamingState.Responding }); + expect(result.current.operationStartTime).toBe(1000); + }); + + it('should reset operationStartTime when PTY ID changes while responding', () => { + const { result, rerender } = renderHook( + ({ state, ptyId }) => useTurnActivityMonitor(state, ptyId, []), + { + initialProps: { + state: StreamingState.Responding, + ptyId: 'pty-1' as string | null, + }, + }, + ); + + expect(result.current.operationStartTime).toBe(1000); + + vi.setSystemTime(2000); + rerender({ state: StreamingState.Responding, ptyId: 'pty-2' }); + expect(result.current.operationStartTime).toBe(2000); + }); + + it('should detect redirection from tool calls', () => { + // Force mock implementation to ensure it's active + vi.mocked(hasRedirection).mockImplementation((q: string) => + q.includes('>'), + ); + + const { result, rerender } = renderHook( + ({ state, pendingToolCalls }) => + useTurnActivityMonitor(state, null, pendingToolCalls), + { + initialProps: { + state: StreamingState.Responding, + pendingToolCalls: [] as TrackedToolCall[], + }, + }, + ); + + expect(result.current.isRedirectionActive).toBe(false); + + // Test non-redirected tool call + rerender({ + state: StreamingState.Responding, + pendingToolCalls: [ + { + request: { + name: 'run_shell_command', + args: { command: 'ls -la' }, + }, + status: 'executing', + } as unknown as TrackedToolCall, + ], + }); + expect(result.current.isRedirectionActive).toBe(false); + + // Test tool call redirection + rerender({ + state: StreamingState.Responding, + pendingToolCalls: [ + { + request: { + name: 'run_shell_command', + args: { command: 'ls > tool_out.txt' }, + }, + status: 'executing', + } as unknown as TrackedToolCall, + ], + }); + expect(result.current.isRedirectionActive).toBe(true); + }); + + it('should reset everything when idle', () => { + const { result, rerender } = renderHook( + ({ state }) => useTurnActivityMonitor(state, 'pty-1', []), + { + initialProps: { state: StreamingState.Responding }, + }, + ); + + expect(result.current.operationStartTime).toBe(1000); + + rerender({ state: StreamingState.Idle }); + expect(result.current.operationStartTime).toBe(0); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts new file mode 100644 index 0000000000..cd6ee7ee8a --- /dev/null +++ b/packages/cli/src/ui/hooks/useTurnActivityMonitor.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef, useMemo } from 'react'; +import { StreamingState } from '../types.js'; +import { hasRedirection } from '@google/gemini-cli-core'; +import { type TrackedToolCall } from './useReactToolScheduler.js'; + +export interface TurnActivityStatus { + operationStartTime: number; + isRedirectionActive: boolean; +} + +/** + * Monitors the activity of a Gemini turn to detect when a new operation starts + * and whether it involves shell redirections that should suppress inactivity prompts. + */ +export const useTurnActivityMonitor = ( + streamingState: StreamingState, + activePtyId: number | string | null | undefined, + pendingToolCalls: TrackedToolCall[] = [], +): TurnActivityStatus => { + const [operationStartTime, setOperationStartTime] = useState(0); + + // Reset operation start time whenever a new operation begins. + // We consider an operation to have started when we enter Responding state, + // OR when the active PTY changes (meaning a new command started within the turn). + const prevPtyIdRef = useRef(undefined); + const prevStreamingStateRef = useRef(undefined); + + useEffect(() => { + const isNowResponding = streamingState === StreamingState.Responding; + const wasResponding = + prevStreamingStateRef.current === StreamingState.Responding; + const ptyChanged = activePtyId !== prevPtyIdRef.current; + + if (isNowResponding && (!wasResponding || ptyChanged)) { + setOperationStartTime(Date.now()); + } else if (!isNowResponding && wasResponding) { + setOperationStartTime(0); + } + + prevPtyIdRef.current = activePtyId; + prevStreamingStateRef.current = streamingState; + }, [streamingState, activePtyId]); + + // Detect redirection in the current query or tool calls. + // We derive this directly during render to ensure it's accurate from the first frame. + const isRedirectionActive = useMemo( + () => + // Check active tool calls for run_shell_command + pendingToolCalls.some((tc) => { + if (tc.request.name !== 'run_shell_command') return false; + + const command = + (tc.request.args as { command?: string })?.command || ''; + return hasRedirection(command); + }), + [pendingToolCalls], + ); + + return { + operationStartTime, + isRedirectionActive, + }; +}; diff --git a/packages/cli/src/utils/windowTitle.test.ts b/packages/cli/src/utils/windowTitle.test.ts index c25f151f83..853ede8db0 100644 --- a/packages/cli/src/utils/windowTitle.test.ts +++ b/packages/cli/src/utils/windowTitle.test.ts @@ -5,7 +5,10 @@ */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { computeTerminalTitle } from './windowTitle.js'; +import { + computeTerminalTitle, + type TerminalTitleOptions, +} from './windowTitle.js'; import { StreamingState } from '../ui/types.js'; describe('computeTerminalTitle', () => { @@ -19,10 +22,11 @@ describe('computeTerminalTitle', () => { args: { streamingState: StreamingState.Idle, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: false, useDynamicTitle: true, - }, + } as TerminalTitleOptions, expected: '◇ Ready (my-project)', }, { @@ -30,10 +34,11 @@ describe('computeTerminalTitle', () => { args: { streamingState: StreamingState.Responding, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: false, - }, + } as TerminalTitleOptions, expected: 'Gemini CLI (my-project)'.padEnd(80, ' '), exact: true, }, @@ -44,10 +49,11 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: 'Reading files', isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: false, useDynamicTitle: true, - }, + } as TerminalTitleOptions, expected: '✦ Working… (my-project)', }, { @@ -57,10 +63,11 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: 'Short thought', isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: true, - }, + } as TerminalTitleOptions, expected: '✦ Short thought (my-project)', }, { @@ -70,10 +77,11 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: undefined, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: true, - }, + } as TerminalTitleOptions, expected: '✦ Working… (my-project)'.padEnd(80, ' '), exact: true, }, @@ -82,12 +90,25 @@ describe('computeTerminalTitle', () => { args: { streamingState: StreamingState.Idle, isConfirming: true, + isSilentWorking: false, folderName: 'my-project', showThoughts: false, useDynamicTitle: true, - }, + } as TerminalTitleOptions, expected: '✋ Action Required (my-project)', }, + { + description: 'silent working state', + args: { + streamingState: StreamingState.Responding, + isConfirming: false, + isSilentWorking: true, + folderName: 'my-project', + showThoughts: false, + useDynamicTitle: true, + } as TerminalTitleOptions, + expected: '⏲ Working… (my-project)', + }, ])('should return $description', ({ args, expected, exact }) => { const title = computeTerminalTitle(args); if (exact) { @@ -104,6 +125,7 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: longThought, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: true, @@ -120,6 +142,7 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: longThought, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: true, @@ -135,6 +158,7 @@ describe('computeTerminalTitle', () => { streamingState: StreamingState.Responding, thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars', isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: true, useDynamicTitle: true, @@ -153,6 +177,7 @@ describe('computeTerminalTitle', () => { const title = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, + isSilentWorking: false, folderName: 'my-project', showThoughts: false, useDynamicTitle: true, @@ -185,6 +210,7 @@ describe('computeTerminalTitle', () => { const title = computeTerminalTitle({ streamingState: StreamingState.Idle, isConfirming: false, + isSilentWorking: false, folderName, showThoughts: false, useDynamicTitle: true, @@ -201,6 +227,7 @@ describe('computeTerminalTitle', () => { const title = computeTerminalTitle({ streamingState: StreamingState.Responding, isConfirming: false, + isSilentWorking: false, folderName: longFolderName, showThoughts: true, useDynamicTitle: false, diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts index 3378119915..1c9ab6dbfb 100644 --- a/packages/cli/src/utils/windowTitle.ts +++ b/packages/cli/src/utils/windowTitle.ts @@ -10,6 +10,7 @@ export interface TerminalTitleOptions { streamingState: StreamingState; thoughtSubject?: string; isConfirming: boolean; + isSilentWorking: boolean; folderName: string; showThoughts: boolean; useDynamicTitle: boolean; @@ -32,6 +33,7 @@ export function computeTerminalTitle({ streamingState, thoughtSubject, isConfirming, + isSilentWorking, folderName, showThoughts, useDynamicTitle, @@ -62,6 +64,12 @@ export function computeTerminalTitle({ const maxContextLen = MAX_LEN - base.length - 3; const context = truncate(displayContext, maxContextLen); title = `${base}${getSuffix(context)}`; + } else if (isSilentWorking) { + const base = '⏲ Working…'; + // Max context length is 80 - base.length - 3 (for ' (' and ')') + const maxContextLen = MAX_LEN - base.length - 3; + const context = truncate(displayContext, maxContextLen); + title = `${base}${getSuffix(context)}`; } else if (streamingState === StreamingState.Idle) { const base = '◇ Ready'; // Max context length is 80 - base.length - 3 (for ' (' and ')')