diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c8925ae3b2..911a0fb114 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -436,6 +436,7 @@ export const AppContainer = (props: AppContainerProps) => { const [isOfflineMode, setIsOfflineMode] = useState( config.isOfflineModeEnabled(), ); + const [cloudSubagentActive, setCloudSubagentActive] = useState(false); const [userTier, setUserTier] = useState(undefined); const [quotaStats, setQuotaStats] = useState(() => { @@ -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, diff --git a/packages/cli/src/ui/components/PulsingDot.tsx b/packages/cli/src/ui/components/PulsingDot.tsx new file mode 100644 index 0000000000..f19b18ad1c --- /dev/null +++ b/packages/cli/src/ui/components/PulsingDot.tsx @@ -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 = ({ + color, + dimColor, + cycleDurationMs, + active, + label, +}) => { + const currentColor = usePulsingColor( + color, + dimColor, + cycleDurationMs, + active, + ); + + return ( + + {active ? '◉' : '●'} {label} + + ); +}; diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index 0383de78ff..fc46f7fafa 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -47,6 +47,8 @@ describe('', () => { showWit: true, modeContentObj: null, showMinimalContext: false, + isOfflineMode: false, + cloudSubagentActive: false, }); const uiState: Partial = { @@ -87,6 +89,8 @@ describe('', () => { showWit: false, modeContentObj: null, showMinimalContext: false, + isOfflineMode: false, + cloudSubagentActive: false, }); const { lastFrame, waitUntilReady } = await renderWithProviders( @@ -116,6 +120,8 @@ describe('', () => { showWit: true, modeContentObj: null, showMinimalContext: false, + isOfflineMode: false, + cloudSubagentActive: false, }); const uiState: Partial = { @@ -150,6 +156,8 @@ describe('', () => { showWit: false, modeContentObj: null, showMinimalContext: false, + isOfflineMode: true, + cloudSubagentActive: false, }); const uiState: Partial = { @@ -175,4 +183,43 @@ describe('', () => { 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 = { + ...defaultUiState, + isOfflineMode: true, + cloudSubagentActive: true, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toContain('offline'); + expect(output).toContain('cloud'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index 21f7d06387..acf251178c 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -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 = ({ 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 = ({ )} - {uiState.isOfflineMode && ( - - ● offline + {isOfflineMode && ( + + + {cloudSubagentActive && ( + + )} )} diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index 995c404d9d..31855daaa9 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -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 ( diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 477885b07f..5ff6e03a5a 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -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( diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index ff4015fd7e..a4891c6f67 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -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', diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap index 6d33b6fbfb..e7d2d2f091 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -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) diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ce2f3fb7b9..77e4a84c1f 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -158,6 +158,7 @@ export interface UIState { showApprovalModeIndicator: ApprovalMode; allowPlanMode: boolean; isOfflineMode?: boolean; + cloudSubagentActive?: boolean; currentModel: string; contextFileNames: string[]; errorCount: number; diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts index fc6614d4f2..49d3c1b819 100644 --- a/packages/cli/src/ui/hooks/useComposerStatus.ts +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -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, }; }; diff --git a/packages/cli/src/ui/hooks/usePulsingColor.ts b/packages/cli/src/ui/hooks/usePulsingColor.ts new file mode 100644 index 0000000000..99dc41fa91 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePulsingColor.ts @@ -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; +} diff --git a/packages/core/src/agents/agent-tool.test.ts b/packages/core/src/agents/agent-tool.test.ts index c63ec224ce..05e4bff9b0 100644 --- a/packages/core/src/agents/agent-tool.test.ts +++ b/packages/core/src/agents/agent-tool.test.ts @@ -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(); diff --git a/packages/core/src/agents/agent-tool.ts b/packages/core/src/agents/agent-tool.ts index 4deb17bc04..35a5bc035c 100644 --- a/packages/core/src/agents/agent-tool.ts +++ b/packages/core/src/agents/agent-tool.ts @@ -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; + } }, ); } diff --git a/packages/core/src/agents/cloud-subagent.ts b/packages/core/src/agents/cloud-subagent.ts index f146ec61e1..3204f5ff5d 100644 --- a/packages/core/src/agents/cloud-subagent.ts +++ b/packages/core/src/agents/cloud-subagent.ts @@ -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, diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index a18a1147c4..56bcc603be 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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 { 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. */