mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
improvements and hardening
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
+13
-113
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -67,10 +67,9 @@ describe('AgentTool', () => {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: { type: 'string' },
|
||||
reason: { type: 'string' },
|
||||
request: { type: 'string' },
|
||||
},
|
||||
required: ['task', 'reason'],
|
||||
required: ['request'],
|
||||
},
|
||||
},
|
||||
modelConfig: { model: 'test', generateContentConfig: {} },
|
||||
@@ -194,7 +193,7 @@ describe('AgentTool', () => {
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'info',
|
||||
title: 'Delegate to cloud-subagent',
|
||||
title: '☁ Delegate to cloud subagent',
|
||||
});
|
||||
// Should NOT delegate to child invocation for confirmation
|
||||
expect(LocalSubagentInvocation).not.toHaveBeenCalled();
|
||||
|
||||
@@ -30,30 +30,18 @@ import {
|
||||
} from '../telemetry/constants.js';
|
||||
import { AGENT_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import { CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
|
||||
const CLOUD_DELEGATION_REASON_MAX_LENGTH = 120;
|
||||
const CLOUD_DELEGATION_TASK_MAX_LENGTH = 140;
|
||||
const CLOUD_DELEGATION_REASON_FALLBACK =
|
||||
'Complex work is better handled by the cloud subagent.';
|
||||
const CLOUD_DELEGATION_TASK_FALLBACK = 'No task summary provided.';
|
||||
const CLOUD_DELEGATION_PROMPT_MAX_LENGTH = 280;
|
||||
|
||||
function summarizeInputText(
|
||||
value: unknown,
|
||||
maxLength: number,
|
||||
): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
function truncateText(value: unknown, maxLength: number): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return 'Cloud delegation requested.';
|
||||
}
|
||||
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
@@ -225,21 +213,15 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
return false;
|
||||
}
|
||||
|
||||
const reason =
|
||||
summarizeInputText(
|
||||
this.mappedInputs['reason'],
|
||||
CLOUD_DELEGATION_REASON_MAX_LENGTH,
|
||||
) ?? CLOUD_DELEGATION_REASON_FALLBACK;
|
||||
const task =
|
||||
summarizeInputText(
|
||||
this.mappedInputs['task'],
|
||||
CLOUD_DELEGATION_TASK_MAX_LENGTH,
|
||||
) ?? CLOUD_DELEGATION_TASK_FALLBACK;
|
||||
const prompt = truncateText(
|
||||
this.mappedInputs['request'] ?? this.params.prompt,
|
||||
CLOUD_DELEGATION_PROMPT_MAX_LENGTH,
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'info',
|
||||
title: 'Delegate to cloud-subagent',
|
||||
prompt: [`Reason: ${reason}`, `Task: ${task}`].join('\n'),
|
||||
title: '☁ Delegate to cloud subagent',
|
||||
prompt: `This will run with full tool access in cloud mode.\n\n${prompt}`,
|
||||
onConfirm: async (_outcome) => {
|
||||
// Policy updates are handled centrally by the scheduler.
|
||||
},
|
||||
@@ -250,6 +232,11 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
const hintedParams = this.withUserHints(this.mappedInputs);
|
||||
const invocation = this.buildChildInvocation(hintedParams);
|
||||
const isCloud = this.definition.name === CLOUD_SUBAGENT_NAME;
|
||||
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(this.definition.name, 'started');
|
||||
}
|
||||
|
||||
return runInDevTraceSpan(
|
||||
{
|
||||
@@ -263,12 +250,30 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
},
|
||||
async ({ metadata }) => {
|
||||
metadata.input = this.params;
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
metadata.output = result;
|
||||
return result;
|
||||
try {
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
metadata.output = result;
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(
|
||||
this.definition.name,
|
||||
'ended',
|
||||
'success',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isCloud) {
|
||||
coreEvents.emitCloudSubagentExecution(
|
||||
this.definition.name,
|
||||
'ended',
|
||||
signal.aborted ? 'cancelled' : 'error',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,17 +31,13 @@ export const CloudSubagent = (
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'The delegated task to execute in the cloud context.',
|
||||
},
|
||||
reason: {
|
||||
request: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Why delegation is necessary (complexity, volume, uncertainty, or long-running work).',
|
||||
'The delegated task to execute in the cloud context. Include both what to do and why cloud delegation is justified.',
|
||||
},
|
||||
},
|
||||
required: ['task', 'reason'],
|
||||
required: ['request'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
@@ -63,13 +59,7 @@ export const CloudSubagent = (
|
||||
},
|
||||
get promptConfig() {
|
||||
return {
|
||||
query: `You are handling a delegated cloud task from the offline-mode orchestrator.
|
||||
|
||||
Delegation reason:
|
||||
${'${reason}'}
|
||||
|
||||
Task:
|
||||
${'${task}'}`,
|
||||
query: '${request}',
|
||||
systemPrompt: `${getCoreSystemPrompt(
|
||||
context.config,
|
||||
/* useMemory */ undefined,
|
||||
|
||||
@@ -62,6 +62,15 @@ export interface OfflineModeChangedPayload {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for cloud subagent execution lifecycle events.
|
||||
*/
|
||||
export interface CloudSubagentExecutionPayload {
|
||||
agentName: string;
|
||||
state: 'started' | 'ended';
|
||||
outcome?: 'success' | 'error' | 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'console-log' event.
|
||||
*/
|
||||
@@ -192,6 +201,7 @@ export enum CoreEvent {
|
||||
UserFeedback = 'user-feedback',
|
||||
ModelChanged = 'model-changed',
|
||||
OfflineModeChanged = 'offline-mode-changed',
|
||||
CloudSubagentExecution = 'cloud-subagent-execution',
|
||||
ConsoleLog = 'console-log',
|
||||
Output = 'output',
|
||||
MemoryChanged = 'memory-changed',
|
||||
@@ -227,6 +237,7 @@ export interface CoreEvents extends ExtensionEvents {
|
||||
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
|
||||
[CoreEvent.ModelChanged]: [ModelChangedPayload];
|
||||
[CoreEvent.OfflineModeChanged]: [OfflineModeChangedPayload];
|
||||
[CoreEvent.CloudSubagentExecution]: [CloudSubagentExecutionPayload];
|
||||
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
|
||||
[CoreEvent.Output]: [OutputPayload];
|
||||
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
|
||||
@@ -344,6 +355,19 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.OfflineModeChanged, payload);
|
||||
}
|
||||
|
||||
emitCloudSubagentExecution(
|
||||
agentName: string,
|
||||
state: 'started' | 'ended',
|
||||
outcome?: 'success' | 'error' | 'cancelled',
|
||||
): void {
|
||||
const payload: CloudSubagentExecutionPayload = {
|
||||
agentName,
|
||||
state,
|
||||
outcome,
|
||||
};
|
||||
this.emit(CoreEvent.CloudSubagentExecution, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that settings have been modified.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user