improvements and hardening

This commit is contained in:
Samee Zahid
2026-04-15 10:48:03 -07:00
parent 27f35c3358
commit 042bd7fd40
15 changed files with 300 additions and 180 deletions
+16
View File
@@ -436,6 +436,7 @@ export const AppContainer = (props: AppContainerProps) => {
const [isOfflineMode, setIsOfflineMode] = useState(
config.isOfflineModeEnabled(),
);
const [cloudSubagentActive, setCloudSubagentActive] = useState(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
@@ -573,6 +574,11 @@ export const AppContainer = (props: AppContainerProps) => {
const handleOfflineModeChanged = (payload: { enabled: boolean }) => {
setIsOfflineMode(payload.enabled);
};
const handleCloudSubagentExecution = (payload: {
state: 'started' | 'ended';
}) => {
setCloudSubagentActive(payload.state === 'started');
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
@@ -588,10 +594,18 @@ export const AppContainer = (props: AppContainerProps) => {
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.on(
CoreEvent.CloudSubagentExecution,
handleCloudSubagentExecution,
);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.off(
CoreEvent.CloudSubagentExecution,
handleCloudSubagentExecution,
);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -2502,6 +2516,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
cloudSubagentActive,
currentModel,
contextFileNames,
errorCount,
@@ -2614,6 +2629,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
cloudSubagentActive,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { usePulsingColor } from '../hooks/usePulsingColor.js';
interface PulsingDotProps {
/** Full-brightness color */
color: string;
/** Dim color at the trough of the pulse */
dimColor: string;
/** Duration of one full pulse cycle in ms */
cycleDurationMs: number;
/** Whether the dot is actively pulsing. When false, renders static at full color. */
active: boolean;
/** Optional label text rendered after the dot */
label?: string;
}
export const PulsingDot: React.FC<PulsingDotProps> = ({
color,
dimColor,
cycleDurationMs,
active,
label,
}) => {
const currentColor = usePulsingColor(
color,
dimColor,
cycleDurationMs,
active,
);
return (
<Text color={currentColor}>
{active ? '◉' : '●'} {label}
</Text>
);
};
@@ -47,6 +47,8 @@ describe('<StatusRow />', () => {
showWit: true,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
@@ -87,6 +89,8 @@ describe('<StatusRow />', () => {
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
@@ -116,6 +120,8 @@ describe('<StatusRow />', () => {
showWit: true,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
@@ -150,6 +156,8 @@ describe('<StatusRow />', () => {
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: true,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
@@ -175,4 +183,43 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('offline');
});
it('renders cloud indicator when cloud subagent is active', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: true,
cloudSubagentActive: true,
});
const uiState: Partial<UIState> = {
...defaultUiState,
isOfflineMode: true,
cloudSubagentActive: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('offline');
expect(output).toContain('cloud');
});
});
+32 -4
View File
@@ -12,7 +12,7 @@ import {
type ThoughtSummary,
} from '@google/gemini-cli-core';
import stripAnsi from 'strip-ansi';
import { type ActiveHook } from '../types.js';
import { type ActiveHook, StreamingState } from '../types.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { theme } from '../semantic-colors.js';
@@ -25,7 +25,9 @@ import { HorizontalLine } from './shared/HorizontalLine.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { PulsingDot } from './PulsingDot.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
/**
* Layout constants to prevent magic numbers.
@@ -173,7 +175,14 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showWit,
modeContentObj,
showMinimalContext,
isOfflineMode,
cloudSubagentActive,
} = useComposerStatus();
const streamingState = useStreamingContext();
const isLocalActive =
isOfflineMode &&
streamingState === StreamingState.Responding &&
!cloudSubagentActive;
const [statusWidth, setStatusWidth] = useState(0);
const [tipWidth, setTipWidth] = useState(0);
@@ -411,9 +420,28 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<RawMarkdownIndicator />
</Box>
)}
{uiState.isOfflineMode && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.success}> offline</Text>
{isOfflineMode && (
<Box
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
flexDirection="row"
gap={1}
>
<PulsingDot
color={theme.status.success}
dimColor={theme.ui.dark}
cycleDurationMs={1500}
active={isLocalActive}
label="offline"
/>
{cloudSubagentActive && (
<PulsingDot
color={theme.status.warning}
dimColor={theme.ui.dark}
cycleDurationMs={800}
active={true}
label="cloud"
/>
)}
</Box>
)}
</>
@@ -62,18 +62,25 @@ export const SubagentProgressDisplay: React.FC<
let headerText: string | undefined;
let headerColor = theme.text.secondary;
const isCloud =
progress.agentName === 'cloud-subagent' ||
progress.agentName === 'cloud_subagent';
const prefix = isCloud ? '☁ Cloud' : `Subagent ${progress.agentName}`;
if (progress.state === 'cancelled') {
headerText = `Subagent ${progress.agentName} was cancelled.`;
headerText = `${prefix} was cancelled.`;
headerColor = theme.status.warning;
} else if (progress.state === 'error') {
headerText = `Subagent ${progress.agentName} failed.`;
headerText = `${prefix} failed.`;
headerColor = theme.status.error;
} else if (progress.state === 'completed') {
headerText = `Subagent ${progress.agentName} completed.`;
headerText = `${prefix} completed.`;
headerColor = theme.status.success;
} else {
headerText = `Running subagent ${progress.agentName}...`;
headerColor = theme.text.primary;
headerText = isCloud
? `☁ Running cloud subagent...`
: `Running subagent ${progress.agentName}...`;
headerColor = isCloud ? theme.status.warning : theme.text.primary;
}
return (
@@ -100,9 +100,9 @@ describe('ToolConfirmationMessage', () => {
it('should use allow/always allow/deny labels for cloud-subagent confirmations', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Delegate to cloud-subagent',
title: 'Delegate to cloud subagent',
prompt:
'Delegating to cloud-subagent for cloud execution.\nReason: Complex task.\nTask: Analyze migration risks.',
'This will run with full tool access in cloud mode.\n\nAnalyze migration risks across the codebase.',
};
const { lastFrame, unmount } = await renderWithProviders(
@@ -372,7 +372,9 @@ export const ToolConfirmationMessage: React.FC<
});
} else if (confirmationDetails.type === 'info') {
const isCloudSubagentConfirmation =
toolName === 'cloud-subagent' || toolName === 'cloud_subagent';
toolName === 'cloud-subagent' ||
toolName === 'cloud_subagent' ||
confirmationDetails.title?.includes('cloud subagent');
options.push({
label: isCloudSubagentConfirmation ? 'Allow' : 'Allow once',
@@ -8,7 +8,7 @@ exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Allow for this file in all future sessions ~/.gemini/policies/auto-saved.toml
4. Modify with external editor
@@ -16,92 +16,6 @@ Apply this change?
"
`;
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large edit diffs 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ... 10 hidden (Ctrl+O) ... │
│ 6 - const oldLine6 = true; │
│ 6 + const newLine6 = true; │
│ 7 - const oldLine7 = true; │
│ 7 + const newLine7 = true; │
│ 8 - const oldLine8 = true; │
│ 8 + const newLine8 = true; │
│ 9 - const oldLine9 = true; │
│ 9 + const newLine9 = true; │
│ 10 - const oldLine10 = true; │
│ 10 + const newLine10 = true; │
│ 11 - const oldLine11 = true; │
│ 11 + const newLine11 = true; │
│ 12 - const oldLine12 = true; │
│ 12 + const newLine12 = true; │
│ 13 - const oldLine13 = true; │
│ 13 + const newLine13 = true; │
│ 14 - const oldLine14 = true; │
│ 14 + const newLine14 = true; │
│ 15 - const oldLine15 = true; │
│ 15 + const newLine15 = true; │
│ 16 - const oldLine16 = true; │
│ 16 + const newLine16 = true; │
│ 17 - const oldLine17 = true; │
│ 17 + const newLine17 = true; │
│ 18 - const oldLine18 = true; │
│ 18 + const newLine18 = true; │
│ 19 - const oldLine19 = true; │
│ 19 + const newLine19 = true; │
│ 20 - const oldLine20 = true; │
│ 20 + const newLine20 = true; │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large exec commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ... 19 hidden (Ctrl+O) ... │
│ echo "Line 20" │
│ echo "Line 21" │
│ echo "Line 22" │
│ echo "Line 23" │
│ echo "Line 24" │
│ echo "Line 25" │
│ echo "Line 26" │
│ echo "Line 27" │
│ echo "Line 28" │
│ echo "Line 29" │
│ echo "Line 30" │
│ echo "Line 31" │
│ echo "Line 32" │
│ echo "Line 33" │
│ echo "Line 34" │
│ echo "Line 35" │
│ echo "Line 36" │
│ echo "Line 37" │
│ echo "Line 38" │
│ echo "Line 39" │
│ echo "Line 40" │
│ echo "Line 41" │
│ echo "Line 42" │
│ echo "Line 43" │
│ echo "Line 44" │
│ echo "Line 45" │
│ echo "Line 46" │
│ echo "Line 47" │
│ echo "Line 48" │
│ echo "Line 49" │
│ echo "Line 50" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
@@ -112,7 +26,7 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo, ls, whoami]?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -125,7 +39,7 @@ URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -135,32 +49,18 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
│ for i in 1 2 3; do │
│ echo $i │
│ done │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)"
`;
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
"MCP Server: testserver
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -175,7 +75,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Modify with external editor
3. No, suggest changes (esc)
"
@@ -189,7 +89,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
@@ -202,7 +102,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -213,7 +113,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -223,7 +123,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -232,7 +132,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -243,7 +143,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -253,7 +153,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -158,6 +158,7 @@ export interface UIState {
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
isOfflineMode?: boolean;
cloudSubagentActive?: boolean;
currentModel: string;
contextFileNames: string[];
errorCount: number;
+15 -2
View File
@@ -22,6 +22,7 @@ export const useComposerStatus = () => {
const quotaState = useQuotaState();
const settings = useSettings();
const isOfflineMode = Boolean(uiState.isOfflineMode);
const cloudSubagentActive = Boolean(uiState.cloudSubagentActive);
const hasPendingToolConfirmation = useMemo(
() =>
@@ -80,14 +81,23 @@ export const useComposerStatus = () => {
})();
if (approvalModeIndicator) {
return isOfflineMode
const suffix = cloudSubagentActive
? ' + cloud'
: isOfflineMode
? ' + offline'
: '';
return suffix
? {
text: `${approvalModeIndicator.text} + offline`,
text: `${approvalModeIndicator.text}${suffix}`,
color: approvalModeIndicator.color,
}
: approvalModeIndicator;
}
if (cloudSubagentActive) {
return { text: 'cloud', color: theme.status.warning };
}
if (isOfflineMode) {
return { text: 'offline', color: theme.status.success };
}
@@ -99,6 +109,7 @@ export const useComposerStatus = () => {
uiState.activeHooks.length,
showApprovalModeIndicator,
isOfflineMode,
cloudSubagentActive,
]);
const showMinimalContext = isContextUsageHigh(
@@ -127,5 +138,7 @@ export const useComposerStatus = () => {
showWit,
modeContentObj,
showMinimalContext,
isOfflineMode,
cloudSubagentActive,
};
};
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { interpolateColor } from '../themes/color-utils.js';
const FRAME_INTERVAL_MS = 60; // ~16fps — smooth enough for a pulse, cheap on CPU
/**
* Returns a color that pulses between `activeColor` and `dimColor` on a sine
* curve. When `active` is false the hook stops its timer and returns
* `activeColor` at full brightness (static dot).
*/
export function usePulsingColor(
activeColor: string,
dimColor: string,
cycleDurationMs: number,
active: boolean,
): string {
const [time, setTime] = useState(0);
useEffect(() => {
if (!active) {
setTime(0);
return;
}
const interval = setInterval(() => {
setTime((prev) => prev + FRAME_INTERVAL_MS);
}, FRAME_INTERVAL_MS);
return () => clearInterval(interval);
}, [active]);
if (!active) {
return activeColor;
}
// Sine oscillation: 0 → 1 → 0 over one cycle
const progress = (Math.sin((2 * Math.PI * time) / cycleDurationMs) + 1) / 2;
return interpolateColor(dimColor, activeColor, progress) || activeColor;
}