fix(cli): refine 'Action Required' indicator and focus hints (#16497)

This commit is contained in:
N. Taylor Mullen
2026-01-13 06:19:53 -08:00
committed by GitHub
parent 7bbfaabffa
commit 304caa4e43
5 changed files with 184 additions and 14 deletions

View File

@@ -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!();
});

View File

@@ -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,
);

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -38,7 +38,7 @@ export const usePhraseCycler = (
loadingPhrases[0],
);
const showShellFocusHint = useInactivityTimer(
isInteractiveShellWaiting && lastOutputTime > 0,
isInteractiveShellWaiting,
lastOutputTime,
SHELL_FOCUS_HINT_DELAY_MS,
);