mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(cli): refine 'Action Required' indicator and focus hints (#16497)
This commit is contained in:
@@ -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!();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -120,6 +120,8 @@ export const useGeminiStream = (
|
||||
const [thought, setThought] = useState<ThoughtSummary | null>(null);
|
||||
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
|
||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||
const [lastGeminiActivityTime, setLastGeminiActivityTime] =
|
||||
useState<number>(0);
|
||||
const processedMemoryToolsRef = useRef<Set<string>>(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,
|
||||
|
||||
@@ -38,7 +38,7 @@ export const usePhraseCycler = (
|
||||
loadingPhrases[0],
|
||||
);
|
||||
const showShellFocusHint = useInactivityTimer(
|
||||
isInteractiveShellWaiting && lastOutputTime > 0,
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
SHELL_FOCUS_HINT_DELAY_MS,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user