diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index fcaa4e012a..dd648d1975 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1238,6 +1238,9 @@ describe('AppContainer State Management', () => { }); it('should show Action Required in title after a delay when shell is awaiting focus', async () => { + const startTime = 1000000; + vi.setSystemTime(startTime); + // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = { ...mockSettings, @@ -1260,8 +1263,12 @@ describe('AppContainer State Management', () => { thought: { subject: 'Executing shell command' }, cancelOngoingRequest: vi.fn(), activePtyId: 'pty-1', + lastOutputTime: 0, }); + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + // Act: Render the container (embeddedShellFocused is false by default in state) const { unmount } = renderAppContainer({ settings: mockSettingsWithTitleEnabled, @@ -1275,20 +1282,110 @@ describe('AppContainer State Management', () => { '✦ Executing shell command', ); - // Fast-forward time by 31 seconds + // Fast-forward time by 40 seconds await act(async () => { - vi.advanceTimersByTime(31000); + await vi.advanceTimersByTimeAsync(40000); }); // Now it should show Action Required - await waitFor(() => { - const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => - call[0].includes('\x1b]0;'), - ); - const lastTitle = titleWrites[titleWrites.length - 1][0]; - expect(lastTitle).toContain('✋ Action Required'); + const titleWritesDelayed = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = titleWritesDelayed[titleWritesDelayed.length - 1][0]; + expect(lastTitle).toContain('✋ Action Required'); + + unmount(); + }); + + it('should NOT show Action Required in title if shell is streaming output', 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 but not focused + let lastOutputTime = 1000; + mockedUseGeminiStream.mockImplementation(() => ({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: 'Executing shell command' }, + cancelOngoingRequest: vi.fn(), + activePtyId: 'pty-1', + lastOutputTime, + })); + + vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); + vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); + + // Act: Render the container + const { unmount, rerender } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, }); + // Fast-forward time by 20 seconds + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + // Update lastOutputTime to simulate new output + lastOutputTime = 21000; + mockedUseGeminiStream.mockImplementation(() => ({ + streamingState: 'responding', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: { subject: 'Executing shell command' }, + cancelOngoingRequest: vi.fn(), + activePtyId: 'pty-1', + lastOutputTime, + })); + + // Rerender to propagate the new lastOutputTime + await act(async () => { + rerender(getAppContainer({ settings: mockSettingsWithTitleEnabled })); + }); + + // Fast-forward time by another 20 seconds + // Total time elapsed: 40s. + // Time since last output: 20s. + // It should NOT show Action Required yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + + const titleWritesAfterOutput = mocks.mockStdout.write.mock.calls.filter( + (call) => call[0].includes('\x1b]0;'), + ); + const lastTitle = + titleWritesAfterOutput[titleWritesAfterOutput.length - 1][0]; + expect(lastTitle).not.toContain('✋ Action Required'); + expect(lastTitle).toContain('✦ Executing shell command'); + + // Fast-forward another 40 seconds (Total 60s since last output) + await act(async () => { + await vi.advanceTimersByTimeAsync(40000); + }); + + // Now it SHOULD show Action Required + const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => + call[0].includes('\x1b]0;'), + ); + const lastTitleFinal = titleWrites[titleWrites.length - 1][0]; + expect(lastTitleFinal).toContain('✋ Action Required'); + unmount(); }); }); @@ -1992,7 +2089,9 @@ describe('AppContainer State Management', () => { }); // Assert: Verify model is updated - expect(capturedUIState.currentModel).toBe('new-model'); + await waitFor(() => { + expect(capturedUIState.currentModel).toBe('new-model'); + }); unmount!(); }); @@ -2123,7 +2222,9 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - expect(mockSetText).toHaveBeenCalledWith('previous message'); + await waitFor(() => { + expect(mockSetText).toHaveBeenCalledWith('previous message'); + }); unmount!(); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5184853e4b..ad5ddc5fed 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -827,10 +827,13 @@ Logging in with Google... Restarting Gemini CLI to continue. lastOutputTimeRef.current = lastOutputTime; }, [lastOutputTime]); - const isShellAwaitingFocus = !!activePtyId && !embeddedShellFocused; + const isShellAwaitingFocus = + !!activePtyId && + !embeddedShellFocused && + config.isInteractiveShellEnabled(); const showShellActionRequired = useInactivityTimer( isShellAwaitingFocus, - isShellAwaitingFocus, + lastOutputTime, SHELL_ACTION_REQUIRED_TITLE_DELAY_MS, ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index bbf6412bc6..5952508bf8 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -100,6 +100,8 @@ vi.mock('./useKeypress.js', () => ({ vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), + activeShellPtyId: null, + lastShellOutputTime: 0, }), })); @@ -238,6 +240,7 @@ describe('useGeminiStream', () => { mockMarkToolsAsSubmitted, vi.fn(), // setToolCallsForDisplay mockCancelAllToolCalls, + 0, // lastToolOutputTime ]); // Reset mocks for GeminiClient instance methods (startChat and sendMessageStream) @@ -2485,6 +2488,61 @@ describe('useGeminiStream', () => { 'gemini-2.5-flash', ); }); + + it('should update lastOutputTime on Gemini thought and content events', async () => { + vi.useFakeTimers(); + const startTime = 1000000; + vi.setSystemTime(startTime); + + // Mock a stream that yields a thought then content + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Thought, + value: { subject: 'Thinking...', description: '' }, + }; + // Advance time for the next event + vi.advanceTimersByTime(1000); + yield { + type: ServerGeminiEventType.Content, + value: 'Hello', + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + // Submit query + await act(async () => { + await result.current.submitQuery('Test query'); + }); + + // Verify lastOutputTime was updated + // It should be the time of the last event (startTime + 1000) + expect(result.current.lastOutputTime).toBe(startTime + 1000); + + vi.useRealTimers(); + }); }); describe('Loop Detection Confirmation', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 113c6a08bf..ec7370ccfa 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -120,6 +120,8 @@ export const useGeminiStream = ( const [thought, setThought] = useState(null); const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] = useStateAndRef(null); + const [lastGeminiActivityTime, setLastGeminiActivityTime] = + useState(0); const processedMemoryToolsRef = useRef>(new Set()); const { startNewPrompt, getPromptCount } = useSessionStats(); const storage = config.storage; @@ -839,9 +841,11 @@ export const useGeminiStream = ( for await (const event of stream) { switch (event.type) { case ServerGeminiEventType.Thought: + setLastGeminiActivityTime(Date.now()); setThought(event.value); break; case ServerGeminiEventType.Content: + setLastGeminiActivityTime(Date.now()); geminiMessageBuffer = handleContentEvent( event.value, geminiMessageBuffer, @@ -1371,7 +1375,11 @@ export const useGeminiStream = ( storage, ]); - const lastOutputTime = Math.max(lastToolOutputTime, lastShellOutputTime); + const lastOutputTime = Math.max( + lastToolOutputTime, + lastShellOutputTime, + lastGeminiActivityTime, + ); return { streamingState, diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 86a7292152..559aaa4a40 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -38,7 +38,7 @@ export const usePhraseCycler = ( loadingPhrases[0], ); const showShellFocusHint = useInactivityTimer( - isInteractiveShellWaiting && lastOutputTime > 0, + isInteractiveShellWaiting, lastOutputTime, SHELL_FOCUS_HINT_DELAY_MS, );