Add low/full CLI error verbosity mode for cleaner UI (#20399)

This commit is contained in:
Dmitry Lyalin
2026-02-27 14:15:10 -05:00
committed by GitHub
parent 1c8951334a
commit 7f8ce8657c
25 changed files with 689 additions and 32 deletions
+1
View File
@@ -72,6 +72,7 @@ they appear in the UI.
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` | | Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` | | Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` | | Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE ### IDE
+6
View File
@@ -322,6 +322,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"tips"` - **Default:** `"tips"`
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"` - **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
- **`ui.errorVerbosity`** (enum):
- **Description:** Controls whether recoverable errors are hidden (low) or
fully shown (full).
- **Default:** `"low"`
- **Values:** `"low"`, `"full"`
- **`ui.customWittyPhrases`** (array): - **`ui.customWittyPhrases`** (array):
- **Description:** Custom witty phrases to display during loading. When - **Description:** Custom witty phrases to display during loading. When
provided, the CLI cycles through these instead of the defaults. provided, the CLI cycles through these instead of the defaults.
@@ -96,6 +96,14 @@ describe('SettingsSchema', () => {
]); ]);
}); });
it('should have errorVerbosity enum property', () => {
const definition = getSettingsSchema().ui?.properties?.errorVerbosity;
expect(definition).toBeDefined();
expect(definition?.type).toBe('enum');
expect(definition?.default).toBe('low');
expect(definition?.options?.map((o) => o.value)).toEqual(['low', 'full']);
});
it('should have checkpointing nested properties', () => { it('should have checkpointing nested properties', () => {
expect( expect(
getSettingsSchema().general?.properties?.checkpointing.properties getSettingsSchema().general?.properties?.checkpointing.properties
+14
View File
@@ -719,6 +719,20 @@ const SETTINGS_SCHEMA = {
{ value: 'off', label: 'Off' }, { value: 'off', label: 'Off' },
], ],
}, },
errorVerbosity: {
type: 'enum',
label: 'Error Verbosity',
category: 'UI',
requiresRestart: false,
default: 'low',
description:
'Controls whether recoverable errors are hidden (low) or fully shown (full).',
showInDialog: true,
options: [
{ value: 'low', label: 'Low' },
{ value: 'full', label: 'Full' },
],
},
customWittyPhrases: { customWittyPhrases: {
type: 'array', type: 'array',
label: 'Custom Witty Phrases', label: 'Custom Witty Phrases',
+2
View File
@@ -712,6 +712,7 @@ export const AppContainer = (props: AppContainerProps) => {
settings, settings,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
onShowAuthSelection: () => setAuthState(AuthState.Updating), onShowAuthSelection: () => setAuthState(AuthState.Updating),
errorVerbosity: settings.merged.ui.errorVerbosity,
}); });
// Derive auth state variables for backward compatibility with UIStateContext // Derive auth state variables for backward compatibility with UIStateContext
@@ -1688,6 +1689,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
retryStatus, retryStatus,
loadingPhrasesMode: settings.merged.ui.loadingPhrases, loadingPhrasesMode: settings.merged.ui.loadingPhrases,
customWittyPhrases: settings.merged.ui.customWittyPhrases, customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
}); });
const handleGlobalKeypress = useCallback( const handleGlobalKeypress = useCallback(
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import type { ConsoleMessageItem } from '../types.js'; import type { ConsoleMessageItem } from '../types.js';
import { Box } from 'ink'; import { Box } from 'ink';
import type React from 'react'; import type React from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
vi.mock('./shared/ScrollableList.js', () => ({ vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({ ScrollableList: ({
@@ -29,13 +30,18 @@ vi.mock('./shared/ScrollableList.js', () => ({
describe('DetailedMessagesDisplay', () => { describe('DetailedMessagesDisplay', () => {
it('renders nothing when messages are empty', async () => { it('renders nothing when messages are empty', async () => {
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay <DetailedMessagesDisplay
messages={[]} messages={[]}
maxHeight={10} maxHeight={10}
width={80} width={80}
hasFocus={false} hasFocus={false}
/>, />,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
); );
await waitUntilReady(); await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe(''); expect(lastFrame({ allowEmpty: true })).toBe('');
@@ -50,13 +56,18 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'debug', content: 'Debug message', count: 1 }, { type: 'debug', content: 'Debug message', count: 1 },
]; ];
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay <DetailedMessagesDisplay
messages={messages} messages={messages}
maxHeight={20} maxHeight={20}
width={80} width={80}
hasFocus={true} hasFocus={true}
/>, />,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
); );
await waitUntilReady(); await waitUntilReady();
const output = lastFrame(); const output = lastFrame();
@@ -65,18 +76,69 @@ describe('DetailedMessagesDisplay', () => {
unmount(); unmount();
}); });
it('hides the F12 hint in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(F12 to close)');
unmount();
});
it('shows the F12 hint in full error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay
messages={messages}
maxHeight={20}
width={80}
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('renders message counts', async () => { it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [ const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 }, { type: 'log', content: 'Repeated message', count: 5 },
]; ];
const { lastFrame, waitUntilReady, unmount } = render( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<DetailedMessagesDisplay <DetailedMessagesDisplay
messages={messages} messages={messages}
maxHeight={10} maxHeight={10}
width={80} width={80}
hasFocus={false} hasFocus={false}
/>, />,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
); );
await waitUntilReady(); await waitUntilReady();
const output = lastFrame(); const output = lastFrame();
@@ -13,6 +13,8 @@ import {
ScrollableList, ScrollableList,
type ScrollableListRef, type ScrollableListRef,
} from './shared/ScrollableList.js'; } from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps { interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[]; messages: ConsoleMessageItem[];
@@ -27,6 +29,10 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => { > = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null); const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3; const borderAndPadding = 3;
@@ -65,7 +71,10 @@ export const DetailedMessagesDisplay: React.FC<
> >
<Box marginBottom={1}> <Box marginBottom={1}>
<Text bold color={theme.text.primary}> <Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text> Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
</Text> </Text>
</Box> </Box>
<Box height={maxHeight} width={width - borderAndPadding}> <Box height={maxHeight} width={width - borderAndPadding}>
+74 -1
View File
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js'; import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js'; import { Footer } from './Footer.js';
import { tildeifyPath, ToolCallDecision } from '@google/gemini-cli-core'; import {
makeFakeConfig,
tildeifyPath,
ToolCallDecision,
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { SessionStatsState } from '../contexts/SessionContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -503,6 +507,75 @@ describe('<Footer />', () => {
unmount(); unmount();
}); });
}); });
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
expect(lastFrame()).not.toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in debug mode even when verbosity is low', async () => {
const debugConfig = makeFakeConfig();
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
config: debugConfig,
uiState: {
sessionStats: mockSessionStats,
errorCount: 1,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('1 error');
unmount();
});
});
}); });
describe('fallback mode display', () => { describe('fallback mode display', () => {
+4 -1
View File
@@ -60,6 +60,9 @@ export const Footer: React.FC = () => {
const showMemoryUsage = const showMemoryUsage =
config.getDebugMode() || settings.merged.ui.showMemoryUsage; config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
const hideCWD = settings.merged.ui.footer.hideCWD; const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus; const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo; const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -180,7 +183,7 @@ export const Footer: React.FC = () => {
</Text> </Text>
</Box> </Box>
)} )}
{!showErrorDetails && errorCount > 0 && ( {showErrorSummary && (
<Box paddingLeft={1} flexDirection="row"> <Box paddingLeft={1} flexDirection="row">
<Text color={theme.ui.comment}>| </Text> <Text color={theme.ui.comment}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} /> <ConsoleSummaryDisplay errorCount={errorCount} />
@@ -25,6 +25,7 @@ import {
GLOB_DISPLAY_NAME, GLOB_DISPLAY_NAME,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import os from 'node:os'; import os from 'node:os';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('<ToolGroupMessage />', () => { describe('<ToolGroupMessage />', () => {
afterEach(() => { afterEach(() => {
@@ -64,6 +65,11 @@ describe('<ToolGroupMessage />', () => {
ideMode: false, ideMode: false,
enableInteractiveShell: true, enableInteractiveShell: true,
}); });
const fullVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'full' },
},
});
describe('Golden Snapshots', () => { describe('Golden Snapshots', () => {
it('renders single successful tool call', async () => { it('renders single successful tool call', async () => {
@@ -73,6 +79,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -104,7 +111,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders( const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig }, { config: baseMockConfig, settings: fullVerbositySettings },
); );
// Should render nothing because all tools in the group are confirming // Should render nothing because all tools in the group are confirming
@@ -140,6 +147,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -160,6 +168,76 @@ describe('<ToolGroupMessage />', () => {
unmount(); unmount();
}); });
it('hides errored tool calls in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'successful-tool',
status: CoreToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'error-tool',
status: CoreToolCallStatus.Error,
resultDisplay: 'Tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('successful-tool');
expect(output).not.toContain('error-tool');
unmount();
});
it('keeps client-initiated errored tool calls visible in low error verbosity mode', async () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'client-error-tool',
status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: 'Client tool failed',
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{
type: 'tool_group',
tools: toolCalls,
},
],
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('client-error-tool');
unmount();
});
it('renders mixed tool calls including shell command', async () => { it('renders mixed tool calls including shell command', async () => {
const toolCalls = [ const toolCalls = [
createToolCall({ createToolCall({
@@ -187,6 +265,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -233,6 +312,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -266,6 +346,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -288,6 +369,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -326,6 +408,7 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>, </Scrollable>,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -356,6 +439,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -406,6 +490,7 @@ describe('<ToolGroupMessage />', () => {
</Scrollable>, </Scrollable>,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -439,6 +524,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -468,6 +554,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -510,6 +597,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
uiState: { uiState: {
pendingHistoryItems: [ pendingHistoryItems: [
{ {
@@ -569,7 +657,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders( const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig }, { config: baseMockConfig, settings: fullVerbositySettings },
); );
await waitUntilReady(); await waitUntilReady();
@@ -599,7 +687,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders( const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig }, { config: baseMockConfig, settings: fullVerbositySettings },
); );
await waitUntilReady(); await waitUntilReady();
@@ -627,7 +715,7 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls} toolCalls={toolCalls}
borderBottom={false} borderBottom={false}
/>, />,
{ config: baseMockConfig }, { config: baseMockConfig, settings: fullVerbositySettings },
); );
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing. // AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
await waitUntilReady(); await waitUntilReady();
@@ -665,7 +753,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount, waitUntilReady } = renderWithProviders( const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{ config: baseMockConfig }, { config: baseMockConfig, settings: fullVerbositySettings },
); );
await waitUntilReady(); await waitUntilReady();
@@ -697,6 +785,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: true, constrainHeight: true,
@@ -728,6 +817,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: true, constrainHeight: true,
@@ -760,6 +850,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: true, constrainHeight: true,
@@ -791,6 +882,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: true, constrainHeight: true,
@@ -818,6 +910,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: false, constrainHeight: false,
@@ -850,6 +943,7 @@ describe('<ToolGroupMessage />', () => {
/>, />,
{ {
config: baseMockConfig, config: baseMockConfig,
settings: fullVerbositySettings,
useAlternateBuffer: true, useAlternateBuffer: true,
uiState: { uiState: {
constrainHeight: true, constrainHeight: true,
@@ -18,7 +18,10 @@ import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js'; import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js'; import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core'; import {
shouldHideToolCall,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js'; import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js'; import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -27,6 +30,7 @@ import {
calculateToolContentMaxLines, calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js'; } from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface ToolGroupMessageProps { interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId; item: HistoryItem | HistoryItemWithoutId;
@@ -51,19 +55,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderBottom: borderBottomOverride, borderBottom: borderBottomOverride,
isExpandable, isExpandable,
}) => { }) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations). // Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo( const toolCalls = useMemo(
() => () =>
allToolCalls.filter( allToolCalls.filter((t) => {
(t) => if (
!shouldHideToolCall({ isLowErrorVerbosity &&
displayName: t.name, t.status === CoreToolCallStatus.Error &&
status: t.status, !t.isClientInitiated
approvalMode: t.approvalMode, ) {
hasResultDisplay: !!t.resultDisplay, return false;
}), }
),
[allToolCalls], return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
});
}),
[allToolCalls, isLowErrorVerbosity],
); );
const config = useConfig(); const config = useConfig();
@@ -50,10 +50,14 @@ describe('ToolResultDisplay Overflow', () => {
await waitUntilReady(); await waitUntilReady();
// ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously // In ASB mode the overflow hint can render before the scroll position
// settles. Wait for both the hint and the tail of the content so this
// snapshot is deterministic across slower CI runners.
await waitFor(() => { await waitFor(() => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); expect(frame).toBeDefined();
expect(frame?.toLowerCase()).toContain('press ctrl+o to show more lines');
expect(frame).toContain('line 50');
}); });
const frame = lastFrame(); const frame = lastFrame();
@@ -413,6 +413,7 @@ async function readMcpResources(
name: `resources/read (${resource.serverName})`, name: `resources/read (${resource.serverName})`,
description: resource.uri, description: resource.uri,
status: CoreToolCallStatus.Success, status: CoreToolCallStatus.Success,
isClientInitiated: true,
resultDisplay: `Successfully read resource ${resource.uri}`, resultDisplay: `Successfully read resource ${resource.uri}`,
confirmationDetails: undefined, confirmationDetails: undefined,
} as IndividualToolCallDisplay, } as IndividualToolCallDisplay,
@@ -427,6 +428,7 @@ async function readMcpResources(
name: `resources/read (${resource.serverName})`, name: `resources/read (${resource.serverName})`,
description: resource.uri, description: resource.uri,
status: CoreToolCallStatus.Error, status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`, resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`,
confirmationDetails: undefined, confirmationDetails: undefined,
} as IndividualToolCallDisplay, } as IndividualToolCallDisplay,
@@ -506,6 +508,7 @@ async function readLocalFiles(
name: readManyFilesTool.displayName, name: readManyFilesTool.displayName,
description: invocation.getDescription(), description: invocation.getDescription(),
status: CoreToolCallStatus.Success, status: CoreToolCallStatus.Success,
isClientInitiated: true,
resultDisplay: resultDisplay:
result.returnDisplay || result.returnDisplay ||
`Successfully read: ${fileLabelsForDisplay.join(', ')}`, `Successfully read: ${fileLabelsForDisplay.join(', ')}`,
@@ -565,6 +568,7 @@ async function readLocalFiles(
invocation?.getDescription() ?? invocation?.getDescription() ??
'Error attempting to execute tool to read files', 'Error attempting to execute tool to read files',
status: CoreToolCallStatus.Error, status: CoreToolCallStatus.Error,
isClientInitiated: true,
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined, confirmationDetails: undefined,
}; };
@@ -305,6 +305,7 @@ export const useShellCommandProcessor = (
name: SHELL_COMMAND_NAME, name: SHELL_COMMAND_NAME,
description: rawQuery, description: rawQuery,
status: CoreToolCallStatus.Executing, status: CoreToolCallStatus.Executing,
isClientInitiated: true,
resultDisplay: '', resultDisplay: '',
confirmationDetails: undefined, confirmationDetails: undefined,
}; };
@@ -581,6 +581,7 @@ export const useSlashCommandProcessor = (
name: 'Expansion', name: 'Expansion',
description: 'Command expansion needs shell access', description: 'Command expansion needs shell access',
status: CoreToolCallStatus.AwaitingApproval, status: CoreToolCallStatus.AwaitingApproval,
isClientInitiated: true,
resultDisplay: undefined, resultDisplay: undefined,
confirmationDetails, confirmationDetails,
}; };
@@ -325,5 +325,33 @@ describe('toolMapping', () => {
const result = mapToDisplay(toolCall); const result = mapToDisplay(toolCall);
expect(result.tools[0].originalRequestName).toBe('original_tool'); expect(result.tools[0].originalRequestName).toBe('original_tool');
}); });
it('propagates isClientInitiated from tool request', () => {
const clientInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
callId: 'call-client',
isClientInitiated: true,
},
tool: mockTool,
invocation: mockInvocation,
};
const modelInitiatedTool: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
...mockRequest,
callId: 'call-model',
isClientInitiated: false,
},
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay([clientInitiatedTool, modelInitiatedTool]);
expect(result.tools[0].isClientInitiated).toBe(true);
expect(result.tools[1].isClientInitiated).toBe(false);
});
}); });
}); });
+1
View File
@@ -101,6 +101,7 @@ export function mapToDisplay(
return { return {
...baseDisplayProperties, ...baseDisplayProperties,
status: call.status, status: call.status,
isClientInitiated: !!call.request.isClientInitiated,
resultDisplay, resultDisplay,
confirmationDetails, confirmationDetails,
outputFile, outputFile,
@@ -335,7 +335,10 @@ describe('useGeminiStream', () => {
}); });
const mockLoadedSettings: LoadedSettings = { const mockLoadedSettings: LoadedSettings = {
merged: { preferredEditor: 'vscode' }, merged: {
preferredEditor: 'vscode',
ui: { errorVerbosity: 'full' },
},
user: { path: '/user/settings.json', settings: {} }, user: { path: '/user/settings.json', settings: {} },
workspace: { path: '/workspace/.gemini/settings.json', settings: {} }, workspace: { path: '/workspace/.gemini/settings.json', settings: {} },
errors: [], errors: [],
@@ -346,6 +349,7 @@ describe('useGeminiStream', () => {
const renderTestHook = ( const renderTestHook = (
initialToolCalls: TrackedToolCall[] = [], initialToolCalls: TrackedToolCall[] = [],
geminiClient?: any, geminiClient?: any,
loadedSettings: LoadedSettings = mockLoadedSettings,
) => { ) => {
const client = geminiClient || mockConfig.getGeminiClient(); const client = geminiClient || mockConfig.getGeminiClient();
let lastToolCalls = initialToolCalls; let lastToolCalls = initialToolCalls;
@@ -360,7 +364,7 @@ describe('useGeminiStream', () => {
cmd: PartListUnion, cmd: PartListUnion,
) => Promise<SlashCommandProcessorResult | false>, ) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: false, shellModeActive: false,
loadedSettings: mockLoadedSettings, loadedSettings,
toolCalls: initialToolCalls, toolCalls: initialToolCalls,
}; };
@@ -969,6 +973,93 @@ describe('useGeminiStream', () => {
// Streaming state should be Idle // Streaming state should be Idle
expect(result.current.streamingState).toBe(StreamingState.Idle); expect(result.current.streamingState).toBe(StreamingState.Idle);
}); });
const infoTexts = mockAddItem.mock.calls.map(
([item]) => (item as { text?: string }).text ?? '',
);
expect(
infoTexts.some((text) =>
text.includes(
'Some internal tool attempts failed before this final error',
),
),
).toBe(false);
expect(
infoTexts.some((text) =>
text.includes('This request failed. Press F12 for diagnostics'),
),
).toBe(false);
});
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
name: 'stopTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-stop',
},
status: CoreToolCallStatus.Error,
response: {
callId: 'stop-call',
responseParts: [{ text: 'error occurred' }],
errorType: ToolErrorType.STOP_EXECUTION,
error: new Error('Stop reason from hook'),
resultDisplay: undefined,
},
responseSubmittedToGemini: false,
tool: {
displayName: 'stop tool',
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
...mockLoadedSettings,
merged: {
...mockLoadedSettings.merged,
ui: { errorVerbosity: 'low' },
},
} as LoadedSettings;
const client = new MockedGeminiClientClass(mockConfig);
const { result } = renderTestHook([], client, lowVerbositySettings);
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete(stopExecutionToolCalls);
}
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['stop-call']);
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
const infoTexts = mockAddItem.mock.calls.map(
([item]) => (item as { text?: string }).text ?? '',
);
const noteIndex = infoTexts.findIndex((text) =>
text.includes(
'Some internal tool attempts failed before this final error',
),
);
const stopIndex = infoTexts.findIndex((text) =>
text.includes('Agent execution stopped: Stop reason from hook'),
);
const failureHintIndex = infoTexts.findIndex((text) =>
text.includes('This request failed. Press F12 for diagnostics'),
);
expect(noteIndex).toBeGreaterThanOrEqual(0);
expect(stopIndex).toBeGreaterThanOrEqual(0);
expect(failureHintIndex).toBeGreaterThanOrEqual(0);
expect(noteIndex).toBeLessThan(stopIndex);
expect(stopIndex).toBeLessThan(failureHintIndex);
}); });
it('should group multiple cancelled tool call responses into a single history entry', async () => { it('should group multiple cancelled tool call responses into a single history entry', async () => {
+99 -3
View File
@@ -107,6 +107,11 @@ enum StreamProcessingStatus {
Error, Error,
} }
const SUPPRESSED_TOOL_ERRORS_NOTE =
'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
const LOW_VERBOSITY_FAILURE_NOTE =
'This request failed. Press F12 for diagnostics, or set ui.errorVerbosity to full for full details.';
function isShellToolData(data: unknown): data is ShellToolData { function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) { if (typeof data !== 'object' || data === null) {
return false; return false;
@@ -202,6 +207,10 @@ export const useGeminiStream = (
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>( const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
null, null,
); );
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
const suppressedToolErrorCountRef = useRef(0);
const suppressedToolErrorNoteShownRef = useRef(false);
const lowVerbosityFailureNoteShownRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false); const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null); const activeQueryIdRef = useRef<string | null>(null);
@@ -559,6 +568,51 @@ export const useGeminiStream = (
} }
}, [isResponding]); }, [isResponding]);
const maybeAddSuppressedToolErrorNote = useCallback(
(userMessageTimestamp?: number) => {
if (!isLowErrorVerbosity) {
return;
}
if (suppressedToolErrorCountRef.current === 0) {
return;
}
if (suppressedToolErrorNoteShownRef.current) {
return;
}
addItem(
{
type: MessageType.INFO,
text: SUPPRESSED_TOOL_ERRORS_NOTE,
},
userMessageTimestamp,
);
suppressedToolErrorNoteShownRef.current = true;
},
[addItem, isLowErrorVerbosity],
);
const maybeAddLowVerbosityFailureNote = useCallback(
(userMessageTimestamp?: number) => {
if (!isLowErrorVerbosity || config.getDebugMode()) {
return;
}
if (lowVerbosityFailureNoteShownRef.current) {
return;
}
addItem(
{
type: MessageType.INFO,
text: LOW_VERBOSITY_FAILURE_NOTE,
},
userMessageTimestamp,
);
lowVerbosityFailureNoteShownRef.current = true;
},
[addItem, config, isLowErrorVerbosity],
);
const cancelOngoingRequest = useCallback(() => { const cancelOngoingRequest = useCallback(() => {
if ( if (
streamingState !== StreamingState.Responding && streamingState !== StreamingState.Responding &&
@@ -908,6 +962,7 @@ export const useGeminiStream = (
addItem(pendingHistoryItemRef.current, userMessageTimestamp); addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null); setPendingHistoryItem(null);
} }
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
addItem( addItem(
{ {
type: MessageType.ERROR, type: MessageType.ERROR,
@@ -921,9 +976,18 @@ export const useGeminiStream = (
}, },
userMessageTimestamp, userMessageTimestamp,
); );
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
setThought(null); // Reset thought when there's an error setThought(null); // Reset thought when there's an error
}, },
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, setThought], [
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
config,
setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
],
); );
const handleCitationEvent = useCallback( const handleCitationEvent = useCallback(
@@ -1086,6 +1150,7 @@ export const useGeminiStream = (
}, },
userMessageTimestamp, userMessageTimestamp,
); );
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
if (contextCleared) { if (contextCleared) {
addItem( addItem(
{ {
@@ -1097,7 +1162,13 @@ export const useGeminiStream = (
} }
setIsResponding(false); setIsResponding(false);
}, },
[addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding], [
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
setIsResponding,
maybeAddLowVerbosityFailureNote,
],
); );
const handleAgentExecutionBlockedEvent = useCallback( const handleAgentExecutionBlockedEvent = useCallback(
@@ -1118,6 +1189,7 @@ export const useGeminiStream = (
}, },
userMessageTimestamp, userMessageTimestamp,
); );
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
if (contextCleared) { if (contextCleared) {
addItem( addItem(
{ {
@@ -1128,7 +1200,12 @@ export const useGeminiStream = (
); );
} }
}, },
[addItem, pendingHistoryItemRef, setPendingHistoryItem], [
addItem,
pendingHistoryItemRef,
setPendingHistoryItem,
maybeAddLowVerbosityFailureNote,
],
); );
const processGeminiStreamEvents = useCallback( const processGeminiStreamEvents = useCallback(
@@ -1286,6 +1363,9 @@ export const useGeminiStream = (
if (!options?.isContinuation) { if (!options?.isContinuation) {
setModelSwitchedFromQuotaError(false); setModelSwitchedFromQuotaError(false);
config.setQuotaErrorOccurred(false); config.setQuotaErrorOccurred(false);
suppressedToolErrorCountRef.current = 0;
suppressedToolErrorNoteShownRef.current = false;
lowVerbosityFailureNoteShownRef.current = false;
} }
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
@@ -1402,6 +1482,7 @@ export const useGeminiStream = (
) { ) {
// Error was handled by validation dialog, don't display again // Error was handled by validation dialog, don't display again
} else if (!isNodeError(error) || error.name !== 'AbortError') { } else if (!isNodeError(error) || error.name !== 'AbortError') {
maybeAddSuppressedToolErrorNote(userMessageTimestamp);
addItem( addItem(
{ {
type: MessageType.ERROR, type: MessageType.ERROR,
@@ -1415,6 +1496,7 @@ export const useGeminiStream = (
}, },
userMessageTimestamp, userMessageTimestamp,
); );
maybeAddLowVerbosityFailureNote(userMessageTimestamp);
} }
} finally { } finally {
if (activeQueryIdRef.current === queryId) { if (activeQueryIdRef.current === queryId) {
@@ -1439,6 +1521,8 @@ export const useGeminiStream = (
startNewPrompt, startNewPrompt,
getPromptCount, getPromptCount,
setThought, setThought,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
], ],
); );
@@ -1587,6 +1671,13 @@ export const useGeminiStream = (
(t) => !t.request.isClientInitiated, (t) => !t.request.isClientInitiated,
); );
if (isLowErrorVerbosity) {
// Low-mode suppression applies only to model-initiated tool failures.
suppressedToolErrorCountRef.current += geminiTools.filter(
(tc) => tc.status === CoreToolCallStatus.Error,
).length;
}
if (geminiTools.length === 0) { if (geminiTools.length === 0) {
return; return;
} }
@@ -1597,10 +1688,12 @@ export const useGeminiStream = (
); );
if (stopExecutionTool && stopExecutionTool.response.error) { if (stopExecutionTool && stopExecutionTool.response.error) {
maybeAddSuppressedToolErrorNote();
addItem({ addItem({
type: MessageType.INFO, type: MessageType.INFO,
text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`, text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
}); });
maybeAddLowVerbosityFailureNote();
setIsResponding(false); setIsResponding(false);
const callIdsToMarkAsSubmitted = geminiTools.map( const callIdsToMarkAsSubmitted = geminiTools.map(
@@ -1706,6 +1799,9 @@ export const useGeminiStream = (
registerBackgroundShell, registerBackgroundShell,
consumeUserHint, consumeUserHint,
config, config,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
maybeAddLowVerbosityFailureNote,
], ],
); );
@@ -35,6 +35,7 @@ describe('useLoadingIndicator', () => {
initialShouldShowFocusHint: boolean = false, initialShouldShowFocusHint: boolean = false,
initialRetryStatus: RetryAttemptPayload | null = null, initialRetryStatus: RetryAttemptPayload | null = null,
loadingPhrasesMode: LoadingPhrasesMode = 'all', loadingPhrasesMode: LoadingPhrasesMode = 'all',
initialErrorVerbosity: 'low' | 'full' = 'full',
) => { ) => {
let hookResult: ReturnType<typeof useLoadingIndicator>; let hookResult: ReturnType<typeof useLoadingIndicator>;
function TestComponent({ function TestComponent({
@@ -42,17 +43,20 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint, shouldShowFocusHint,
retryStatus, retryStatus,
mode, mode,
errorVerbosity,
}: { }: {
streamingState: StreamingState; streamingState: StreamingState;
shouldShowFocusHint?: boolean; shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null; retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode; mode?: LoadingPhrasesMode;
errorVerbosity?: 'low' | 'full';
}) { }) {
hookResult = useLoadingIndicator({ hookResult = useLoadingIndicator({
streamingState, streamingState,
shouldShowFocusHint: !!shouldShowFocusHint, shouldShowFocusHint: !!shouldShowFocusHint,
retryStatus: retryStatus || null, retryStatus: retryStatus || null,
loadingPhrasesMode: mode, loadingPhrasesMode: mode,
errorVerbosity,
}); });
return null; return null;
} }
@@ -62,6 +66,7 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint={initialShouldShowFocusHint} shouldShowFocusHint={initialShouldShowFocusHint}
retryStatus={initialRetryStatus} retryStatus={initialRetryStatus}
mode={loadingPhrasesMode} mode={loadingPhrasesMode}
errorVerbosity={initialErrorVerbosity}
/>, />,
); );
return { return {
@@ -75,7 +80,15 @@ describe('useLoadingIndicator', () => {
shouldShowFocusHint?: boolean; shouldShowFocusHint?: boolean;
retryStatus?: RetryAttemptPayload | null; retryStatus?: RetryAttemptPayload | null;
mode?: LoadingPhrasesMode; mode?: LoadingPhrasesMode;
}) => rerender(<TestComponent mode={loadingPhrasesMode} {...newProps} />), errorVerbosity?: 'low' | 'full';
}) =>
rerender(
<TestComponent
mode={loadingPhrasesMode}
errorVerbosity={initialErrorVerbosity}
{...newProps}
/>,
),
}; };
}; };
@@ -229,6 +242,46 @@ describe('useLoadingIndicator', () => {
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3'); expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
}); });
it('should hide low-verbosity retry status for early retry attempts', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 1,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).not.toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show a generic retry phrase in low error verbosity mode for later retries', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 5,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Responding,
false,
retryStatus,
'all',
'low',
);
expect(result.current.currentLoadingPhrase).toBe(
"This is taking a bit longer, we're still on it.",
);
});
it('should show no phrases when loadingPhrasesMode is "off"', () => { it('should show no phrases when loadingPhrasesMode is "off"', () => {
const { result } = renderLoadingIndicatorHook( const { result } = renderLoadingIndicatorHook(
StreamingState.Responding, StreamingState.Responding,
@@ -14,12 +14,15 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { LoadingPhrasesMode } from '../../config/settings.js'; import type { LoadingPhrasesMode } from '../../config/settings.js';
const LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD = 2;
export interface UseLoadingIndicatorProps { export interface UseLoadingIndicatorProps {
streamingState: StreamingState; streamingState: StreamingState;
shouldShowFocusHint: boolean; shouldShowFocusHint: boolean;
retryStatus: RetryAttemptPayload | null; retryStatus: RetryAttemptPayload | null;
loadingPhrasesMode?: LoadingPhrasesMode; loadingPhrasesMode?: LoadingPhrasesMode;
customWittyPhrases?: string[]; customWittyPhrases?: string[];
errorVerbosity?: 'low' | 'full';
} }
export const useLoadingIndicator = ({ export const useLoadingIndicator = ({
@@ -28,6 +31,7 @@ export const useLoadingIndicator = ({
retryStatus, retryStatus,
loadingPhrasesMode, loadingPhrasesMode,
customWittyPhrases, customWittyPhrases,
errorVerbosity = 'full',
}: UseLoadingIndicatorProps) => { }: UseLoadingIndicatorProps) => {
const [timerResetKey, setTimerResetKey] = useState(0); const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding; const isTimerActive = streamingState === StreamingState.Responding;
@@ -70,7 +74,11 @@ export const useLoadingIndicator = ({
}, [streamingState, elapsedTimeFromTimer]); }, [streamingState, elapsedTimeFromTimer]);
const retryPhrase = retryStatus const retryPhrase = retryStatus
? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})` ? errorVerbosity === 'low'
? retryStatus.attempt >= LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD
? "This is taking a bit longer, we're still on it."
: null
: `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
: null; : null;
return { return {
@@ -161,6 +161,67 @@ describe('useQuotaAndFallback', () => {
); );
}); });
it('should auto-retry transient capacity failures in low verbosity mode', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
const intent = await handler(
'gemini-pro',
'gemini-flash',
new RetryableQuotaError('retryable quota', mockGoogleApiError, 5),
);
expect(intent).toBe('retry_once');
expect(result.current.proQuotaRequest).toBeNull();
expect(mockSetModelSwitchedFromQuotaError).not.toHaveBeenCalledWith(true);
expect(mockConfig.setQuotaErrorOccurred).not.toHaveBeenCalledWith(true);
});
it('should still prompt for terminal quota in low verbosity mode', async () => {
const { result } = renderHook(() =>
useQuotaAndFallback({
config: mockConfig,
historyManager: mockHistoryManager,
userTier: UserTierId.FREE,
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
onShowAuthSelection: mockOnShowAuthSelection,
paidTier: null,
settings: mockSettings,
errorVerbosity: 'low',
}),
);
const handler = setFallbackHandlerSpy.mock
.calls[0][0] as FallbackModelHandler;
let promise: Promise<FallbackIntent | null>;
act(() => {
promise = handler(
'gemini-pro',
'gemini-flash',
new TerminalQuotaError('pro quota', mockGoogleApiError),
);
});
expect(result.current.proQuotaRequest).not.toBeNull();
act(() => {
result.current.handleProQuotaChoice('retry_later');
});
await promise!;
});
describe('Interactive Fallback', () => { describe('Interactive Fallback', () => {
it('should set an interactive request for a terminal quota error', async () => { it('should set an interactive request for a terminal quota error', async () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -42,6 +42,7 @@ interface UseQuotaAndFallbackArgs {
settings: LoadedSettings; settings: LoadedSettings;
setModelSwitchedFromQuotaError: (value: boolean) => void; setModelSwitchedFromQuotaError: (value: boolean) => void;
onShowAuthSelection: () => void; onShowAuthSelection: () => void;
errorVerbosity?: 'low' | 'full';
} }
export function useQuotaAndFallback({ export function useQuotaAndFallback({
@@ -52,6 +53,7 @@ export function useQuotaAndFallback({
settings, settings,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
onShowAuthSelection, onShowAuthSelection,
errorVerbosity = 'full',
}: UseQuotaAndFallbackArgs) { }: UseQuotaAndFallbackArgs) {
const [proQuotaRequest, setProQuotaRequest] = const [proQuotaRequest, setProQuotaRequest] =
useState<ProQuotaDialogRequest | null>(null); useState<ProQuotaDialogRequest | null>(null);
@@ -165,6 +167,16 @@ export function useQuotaAndFallback({
message = messageLines.join('\n'); message = messageLines.join('\n');
} }
// In low verbosity mode, auto-retry transient capacity failures
// without interrupting with a dialog.
if (
errorVerbosity === 'low' &&
!isTerminalQuotaError &&
!isModelNotFoundError
) {
return 'retry_once';
}
setModelSwitchedFromQuotaError(true); setModelSwitchedFromQuotaError(true);
config.setQuotaErrorOccurred(true); config.setQuotaErrorOccurred(true);
@@ -200,6 +212,7 @@ export function useQuotaAndFallback({
initialOverageStrategy, initialOverageStrategy,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
onShowAuthSelection, onShowAuthSelection,
errorVerbosity,
]); ]);
// Set up validation handler for 403 VALIDATION_REQUIRED errors // Set up validation handler for 403 VALIDATION_REQUIRED errors
+2
View File
@@ -102,6 +102,8 @@ export interface IndividualToolCallDisplay {
description: string; description: string;
resultDisplay: ToolResultDisplay | undefined; resultDisplay: ToolResultDisplay | undefined;
status: CoreToolCallStatus; status: CoreToolCallStatus;
// True when the tool was initiated directly by the user (slash/@/shell flows).
isClientInitiated?: boolean;
confirmationDetails: SerializableConfirmationDetails | undefined; confirmationDetails: SerializableConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean; renderOutputAsMarkdown?: boolean;
ptyId?: number; ptyId?: number;
+8
View File
@@ -436,6 +436,14 @@
"type": "string", "type": "string",
"enum": ["tips", "witty", "all", "off"] "enum": ["tips", "witty", "all", "off"]
}, },
"errorVerbosity": {
"title": "Error Verbosity",
"description": "Controls whether recoverable errors are hidden (low) or fully shown (full).",
"markdownDescription": "Controls whether recoverable errors are hidden (low) or fully shown (full).\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `low`",
"default": "low",
"type": "string",
"enum": ["low", "full"]
},
"customWittyPhrases": { "customWittyPhrases": {
"title": "Custom Witty Phrases", "title": "Custom Witty Phrases",
"description": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.", "description": "Custom witty phrases to display during loading. When provided, the CLI cycles through these instead of the defaults.",