feat(cli): consolidate shell inactivity and redirection monitoring (#17086)

This commit is contained in:
N. Taylor Mullen
2026-01-21 14:31:24 -08:00
committed by GitHub
parent a1233e7e5c
commit 1c9a57c3c2
17 changed files with 675 additions and 157 deletions
+1
View File
@@ -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,
+138 -3
View File
@@ -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(),
+19 -17
View File
@@ -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,
@@ -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();
});
});
@@ -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)
"
`;
+1
View File
@@ -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/';
@@ -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"`;
@@ -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<typeof useLoadingIndicator>;
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(
<TestComponent
streamingState={initialStreamingState}
isInteractiveShellWaiting={initialIsInteractiveShellWaiting}
lastOutputTime={initialLastOutputTime}
shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus}
/>,
);
@@ -72,8 +66,7 @@ describe('useLoadingIndicator', () => {
},
rerender: (newProps: {
streamingState: StreamingState;
isInteractiveShellWaiting?: boolean;
lastOutputTime?: number;
shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null;
}) => rerender(<TestComponent {...newProps} />),
};
@@ -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');
});
});
@@ -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 {
@@ -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 <Text>{phrase}</Text>;
@@ -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(
<TestComponent isActive={true} isWaiting={false} />,
);
@@ -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(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
lastOutputTime={1000}
/>,
);
// 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(
<TestComponent
isActive={true}
isWaiting={false}
isInteractiveShellWaiting={true}
lastOutputTime={4000}
/>,
);
// 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(
<TestComponent isActive={true} isWaiting={true} />,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(lastFrame()).toBe('Waiting for user confirmation...');
expect(lastFrame()).toMatchSnapshot();
rerender(
<TestComponent
isActive={true}
isWaiting={true}
isInteractiveShellWaiting={true}
lastOutputTime={1}
/>,
);
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(<TestComponent isActive={true} isWaiting={false} />);
+5 -19
View File
@@ -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<NodeJS.Timeout | null>(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;
};
@@ -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);
});
});
@@ -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,
};
};
@@ -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<Record<string, unknown>>();
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);
});
});
@@ -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<number | string | null | undefined>(undefined);
const prevStreamingStateRef = useRef<StreamingState | undefined>(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,
};
};
+34 -7
View File
@@ -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,
+8
View File
@@ -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 ')')