mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 11:04:42 -07:00
feat(cli): consolidate shell inactivity and redirection monitoring (#17086)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+15
@@ -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)
|
||||
"
|
||||
`;
|
||||
@@ -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,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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ')')
|
||||
|
||||
Reference in New Issue
Block a user