mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 02:20:42 -07:00
fix(cli): resolve subagent grouping and UI state persistence (#22252)
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: ({ text }: { text: string }) => <Text>{text}</Text>,
|
||||
}));
|
||||
|
||||
describe('<SubagentGroupDisplay />', () => {
|
||||
const mockToolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'agent_1',
|
||||
description: 'Test agent 1',
|
||||
confirmationDetails: undefined,
|
||||
status: CoreToolCallStatus.Executing,
|
||||
kind: Kind.Agent,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'api-monitor',
|
||||
state: 'running',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-1',
|
||||
type: 'tool_call',
|
||||
status: 'running',
|
||||
content: '',
|
||||
displayName: 'Action Required',
|
||||
description: 'Verify server is running',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
callId: 'call-2',
|
||||
name: 'agent_2',
|
||||
description: 'Test agent 2',
|
||||
confirmationDetails: undefined,
|
||||
status: CoreToolCallStatus.Success,
|
||||
kind: Kind.Agent,
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'db-manager',
|
||||
state: 'completed',
|
||||
result: 'Database schema validated',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-2',
|
||||
type: 'thought',
|
||||
status: 'completed',
|
||||
content: 'Database schema validated',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const renderSubagentGroup = (
|
||||
toolCallsToRender: IndividualToolCallDisplay[],
|
||||
height?: number,
|
||||
) => (
|
||||
<OverflowProvider>
|
||||
<KeypressProvider>
|
||||
<SubagentGroupDisplay
|
||||
toolCalls={toolCallsToRender}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
isExpandable={true}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</OverflowProvider>
|
||||
);
|
||||
|
||||
it('renders nothing if there are no agent tool calls', async () => {
|
||||
const { lastFrame } = render(renderSubagentGroup([], 40));
|
||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||
});
|
||||
|
||||
it('renders collapsed view by default with correct agent counts and states', async () => {
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
renderSubagentGroup(mockToolCalls, 40),
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('expands when availableTerminalHeight is undefined', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
renderSubagentGroup(mockToolCalls, 40),
|
||||
);
|
||||
|
||||
// Default collapsed view
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to expand)');
|
||||
});
|
||||
|
||||
// Expand view
|
||||
rerender(renderSubagentGroup(mockToolCalls, undefined));
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to collapse)');
|
||||
});
|
||||
|
||||
// Collapse view
|
||||
rerender(renderSubagentGroup(mockToolCalls, 40));
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(ctrl+o to expand)');
|
||||
});
|
||||
});
|
||||
});
|
||||
269
packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx
Normal file
269
packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import {
|
||||
isSubagentProgress,
|
||||
checkExhaustive,
|
||||
type SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SubagentProgressDisplay,
|
||||
formatToolArgs,
|
||||
} from './SubagentProgressDisplay.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
export interface SubagentGroupDisplayProps {
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
borderColor?: string;
|
||||
borderDimColor?: boolean;
|
||||
isFirst?: boolean;
|
||||
isExpandable?: boolean;
|
||||
}
|
||||
|
||||
export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
toolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isFirst,
|
||||
isExpandable = true,
|
||||
}) => {
|
||||
const isExpanded = availableTerminalHeight === undefined;
|
||||
const overflowActions = useOverflowActions();
|
||||
const uniqueId = useId();
|
||||
const overflowId = `subagent-${uniqueId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpandable && overflowActions) {
|
||||
// Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer
|
||||
// and AppContainer passes the shortcut through.
|
||||
overflowActions.addOverflowingId(overflowId);
|
||||
}
|
||||
return () => {
|
||||
if (overflowActions) {
|
||||
overflowActions.removeOverflowingId(overflowId);
|
||||
}
|
||||
};
|
||||
}, [isExpandable, overflowActions, overflowId]);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let headerText = '';
|
||||
if (toolCalls.length === 1) {
|
||||
const singleAgent = toolCalls[0].resultDisplay;
|
||||
if (isSubagentProgress(singleAgent)) {
|
||||
switch (singleAgent.state) {
|
||||
case 'completed':
|
||||
headerText = 'Agent Completed';
|
||||
break;
|
||||
case 'cancelled':
|
||||
headerText = 'Agent Cancelled';
|
||||
break;
|
||||
case 'error':
|
||||
headerText = 'Agent Error';
|
||||
break;
|
||||
default:
|
||||
headerText = 'Running Agent...';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
headerText = 'Running Agent...';
|
||||
}
|
||||
} else {
|
||||
let completedCount = 0;
|
||||
let runningCount = 0;
|
||||
for (const tc of toolCalls) {
|
||||
const progress = tc.resultDisplay;
|
||||
if (isSubagentProgress(progress)) {
|
||||
if (progress.state === 'completed') completedCount++;
|
||||
else if (progress.state === 'running') runningCount++;
|
||||
} else {
|
||||
// It hasn't emitted progress yet, but it is "running"
|
||||
runningCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount === toolCalls.length) {
|
||||
headerText = `${toolCalls.length} Agents Completed`;
|
||||
} else if (completedCount > 0) {
|
||||
headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`;
|
||||
} else {
|
||||
headerText = `Running ${toolCalls.length} Agents...`;
|
||||
}
|
||||
}
|
||||
const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`;
|
||||
|
||||
const renderCollapsedRow = (
|
||||
key: string,
|
||||
agentName: string,
|
||||
icon: React.ReactNode,
|
||||
content: string,
|
||||
displayArgs?: string,
|
||||
) => (
|
||||
<Box key={key} flexDirection="row" marginLeft={0} marginTop={0}>
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary} wrap="truncate">
|
||||
{agentName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
</Box>
|
||||
<Box flexShrink={1} minWidth={0}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{content}
|
||||
{displayArgs && ` ${displayArgs}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={isFirst}
|
||||
borderBottom={false}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
paddingLeft={1}
|
||||
paddingTop={0}
|
||||
paddingBottom={0}
|
||||
>
|
||||
<Box flexDirection="row" gap={1} marginBottom={isExpanded ? 1 : 0}>
|
||||
<Text color={theme.text.secondary}>≡</Text>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{headerText}
|
||||
</Text>
|
||||
{isExpandable && <Text color={theme.text.secondary}>{toggleText}</Text>}
|
||||
</Box>
|
||||
|
||||
{toolCalls.map((toolCall) => {
|
||||
const progress = toolCall.resultDisplay;
|
||||
|
||||
if (!isSubagentProgress(progress)) {
|
||||
const agentName = toolCall.name || 'agent';
|
||||
if (!isExpanded) {
|
||||
return renderCollapsedRow(
|
||||
toolCall.callId,
|
||||
agentName,
|
||||
<Text color={theme.text.primary}>!</Text>,
|
||||
'Starting...',
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box
|
||||
key={toolCall.callId}
|
||||
flexDirection="column"
|
||||
marginLeft={0}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color={theme.text.primary}>!</Text>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{agentName}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>Starting...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivity: SubagentActivityItem | undefined =
|
||||
progress.recentActivity[progress.recentActivity.length - 1];
|
||||
|
||||
// Collapsed View: Show single compact line per agent
|
||||
if (!isExpanded) {
|
||||
let content = 'Starting...';
|
||||
let formattedArgs: string | undefined;
|
||||
|
||||
if (progress.state === 'completed') {
|
||||
if (
|
||||
progress.terminateReason &&
|
||||
progress.terminateReason !== 'GOAL'
|
||||
) {
|
||||
content = `Finished Early (${progress.terminateReason})`;
|
||||
} else {
|
||||
content = 'Completed successfully';
|
||||
}
|
||||
} else if (lastActivity) {
|
||||
// Match expanded view logic exactly:
|
||||
// Primary text: displayName || content
|
||||
content = lastActivity.displayName || lastActivity.content;
|
||||
|
||||
// Secondary text: description || formatToolArgs(args)
|
||||
if (lastActivity.description) {
|
||||
formattedArgs = lastActivity.description;
|
||||
} else if (lastActivity.type === 'tool_call' && lastActivity.args) {
|
||||
formattedArgs = formatToolArgs(lastActivity.args);
|
||||
}
|
||||
}
|
||||
|
||||
const displayArgs =
|
||||
progress.state === 'completed' ? '' : formattedArgs;
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
const state = progress.state ?? 'running';
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return <Text color={theme.text.primary}>!</Text>;
|
||||
case 'completed':
|
||||
return <Text color={theme.status.success}>✓</Text>;
|
||||
case 'cancelled':
|
||||
return <Text color={theme.status.warning}>ℹ</Text>;
|
||||
case 'error':
|
||||
return <Text color={theme.status.error}>✗</Text>;
|
||||
default:
|
||||
return checkExhaustive(state);
|
||||
}
|
||||
};
|
||||
|
||||
return renderCollapsedRow(
|
||||
toolCall.callId,
|
||||
progress.agentName,
|
||||
renderStatusIcon(),
|
||||
lastActivity?.type === 'thought' ? `💭 ${content}` : content,
|
||||
displayArgs,
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View: Render full history
|
||||
return (
|
||||
<Box
|
||||
key={toolCall.callId}
|
||||
flexDirection="column"
|
||||
marginLeft={0}
|
||||
marginBottom={1}
|
||||
>
|
||||
<SubagentProgressDisplay
|
||||
progress={progress}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -36,7 +36,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -60,7 +60,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -82,7 +82,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -104,7 +104,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -128,7 +128,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -164,7 +164,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -185,7 +185,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
};
|
||||
|
||||
const { lastFrame, waitUntilReady } = render(
|
||||
<SubagentProgressDisplay progress={progress} />,
|
||||
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -8,18 +8,21 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import type {
|
||||
SubagentProgress,
|
||||
SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
|
||||
import { safeJsonToMarkdown } from '@google/gemini-cli-core';
|
||||
|
||||
export interface SubagentProgressDisplayProps {
|
||||
progress: SubagentProgress;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const formatToolArgs = (args?: string): string => {
|
||||
export const formatToolArgs = (args?: string): string => {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(args);
|
||||
@@ -54,7 +57,7 @@ const formatToolArgs = (args?: string): string => {
|
||||
|
||||
export const SubagentProgressDisplay: React.FC<
|
||||
SubagentProgressDisplayProps
|
||||
> = ({ progress }) => {
|
||||
> = ({ progress, terminalWidth }) => {
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
@@ -67,6 +70,9 @@ export const SubagentProgressDisplay: React.FC<
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
} else {
|
||||
headerText = `Running subagent ${progress.agentName}...`;
|
||||
headerColor = theme.text.primary;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -146,6 +152,23 @@ export const SubagentProgressDisplay: React.FC<
|
||||
return null;
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{progress.state === 'completed' && progress.result && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Agent Finished Early ({progress.terminateReason})
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<MarkdownDisplay
|
||||
text={safeJsonToMarkdown(progress.result)}
|
||||
isPending={false}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,12 +15,14 @@ import type {
|
||||
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ShellToolMessage } from './ShellToolMessage.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
@@ -125,12 +127,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
if (
|
||||
tool.kind !== Kind.Agent &&
|
||||
tool.resultDisplay !== undefined &&
|
||||
tool.resultDisplay !== ''
|
||||
) {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
visibleToolCalls.length - countToolCallsWithResults;
|
||||
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
|
||||
countToolCallsWithResults;
|
||||
const groupedTools = useMemo(() => {
|
||||
const groups: Array<
|
||||
IndividualToolCallDisplay | IndividualToolCallDisplay[]
|
||||
> = [];
|
||||
for (const tool of visibleToolCalls) {
|
||||
if (tool.kind === Kind.Agent) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (Array.isArray(lastGroup)) {
|
||||
lastGroup.push(tool);
|
||||
} else {
|
||||
groups.push([tool]);
|
||||
}
|
||||
} else {
|
||||
groups.push(tool);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [visibleToolCalls]);
|
||||
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
@@ -167,8 +193,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
{groupedTools.map((group, index) => {
|
||||
const isFirst = index === 0;
|
||||
const resolvedIsFirst =
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst;
|
||||
|
||||
if (Array.isArray(group)) {
|
||||
return (
|
||||
<SubagentGroupDisplay
|
||||
key={group[0].callId}
|
||||
toolCalls={group}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={contentWidth}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
isFirst={resolvedIsFirst}
|
||||
isExpandable={isExpandable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tool = group;
|
||||
const isShellToolCall = isShellTool(tool.name);
|
||||
|
||||
const commonProps = {
|
||||
@@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst:
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst,
|
||||
isFirst: resolvedIsFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
</Text>
|
||||
);
|
||||
} else if (isSubagentProgress(contentData)) {
|
||||
content = <SubagentProgressDisplay progress={contentData} />;
|
||||
content = (
|
||||
<SubagentProgressDisplay
|
||||
progress={contentData}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
|
||||
content = (
|
||||
<MarkdownDisplay
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentGroupDisplay /> > renders collapsed view by default with correct agent counts and states 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ≡ 2 Agents (1 running, 1 completed)... (ctrl+o to expand) │
|
||||
│ ! api-monitor · Action Required Verify server is running │
|
||||
│ ✓ db-manager · 💭 Completed successfully │
|
||||
"
|
||||
`;
|
||||
@@ -1,7 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
|
||||
"ℹ Request cancelled.
|
||||
"Running subagent TestAgent...
|
||||
|
||||
ℹ Request cancelled.
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -11,31 +13,43 @@ exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
|
||||
"⠋ run_shell_command echo hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
|
||||
"⠋ run_shell_command Say hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command Say hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
|
||||
"⠋ RunShellCommand Executing echo hello
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ RunShellCommand Executing echo hello
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
|
||||
"✓ write_file /tmp/test.txt
|
||||
"Running subagent TestAgent...
|
||||
|
||||
✓ write_file /tmp/test.txt
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
|
||||
"💭 Thinking about life
|
||||
"Running subagent TestAgent...
|
||||
|
||||
💭 Thinking about life
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
|
||||
"⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"Running subagent TestAgent...
|
||||
|
||||
⠋ run_shell_command This is a very long description that should definitely be tr...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -408,7 +409,8 @@ export const useGeminiStream = (
|
||||
// Push completed tools to history as they finish
|
||||
useEffect(() => {
|
||||
const toolsToPush: TrackedToolCall[] = [];
|
||||
for (const tc of toolCalls) {
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const tc = toolCalls[i];
|
||||
if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;
|
||||
|
||||
if (
|
||||
@@ -416,6 +418,40 @@ export const useGeminiStream = (
|
||||
tc.status === 'error' ||
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
// TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents
|
||||
// from tearing visually when they finish at slightly different times.
|
||||
// Architecturally, `useGeminiStream` should not be responsible for stitching
|
||||
// together semantic batches using timing/refs. `packages/core` should be
|
||||
// refactored to emit structured `ToolBatch` or `Turn` objects, and this layer
|
||||
// should simply render those semantic boundaries.
|
||||
// If this is an agent tool, look ahead to ensure all subsequent
|
||||
// contiguous agents in the same batch are also finished before pushing.
|
||||
const isAgent = tc.tool?.kind === Kind.Agent;
|
||||
if (isAgent) {
|
||||
let contigAgentsComplete = true;
|
||||
for (let j = i + 1; j < toolCalls.length; j++) {
|
||||
const nextTc = toolCalls[j];
|
||||
if (nextTc.tool?.kind === Kind.Agent) {
|
||||
if (
|
||||
nextTc.status !== 'success' &&
|
||||
nextTc.status !== 'error' &&
|
||||
nextTc.status !== 'cancelled'
|
||||
) {
|
||||
contigAgentsComplete = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// End of the contiguous agent block
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contigAgentsComplete) {
|
||||
// Wait for the entire contiguous block of agents to finish
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
toolsToPush.push(tc);
|
||||
} else {
|
||||
// Stop at first non-terminal tool to preserve order
|
||||
@@ -425,27 +461,27 @@ export const useGeminiStream = (
|
||||
|
||||
if (toolsToPush.length > 0) {
|
||||
const newPushed = new Set(pushedToolCallIdsRef.current);
|
||||
let isFirst = isFirstToolInGroupRef.current;
|
||||
|
||||
for (const tc of toolsToPush) {
|
||||
newPushed.add(tc.request.callId);
|
||||
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(tc, {
|
||||
borderTop: isFirst,
|
||||
borderBottom: isLastInBatch,
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
isFirst = false;
|
||||
}
|
||||
|
||||
const isLastInBatch =
|
||||
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
|
||||
|
||||
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
|
||||
borderTop: isFirstToolInGroupRef.current,
|
||||
borderBottom: isLastInBatch,
|
||||
...getToolGroupBorderAppearance(
|
||||
{ type: 'tool_group', tools: toolCalls },
|
||||
activeShellPtyId,
|
||||
!!isShellFocused,
|
||||
[],
|
||||
backgroundShells,
|
||||
),
|
||||
});
|
||||
addItem(historyItem);
|
||||
|
||||
setPushedToolCallIds(newPushed);
|
||||
setIsFirstToolInGroup(false);
|
||||
}
|
||||
|
||||
@@ -207,8 +207,11 @@ describe('LocalSubagentInvocation', () => {
|
||||
),
|
||||
},
|
||||
]);
|
||||
expect(result.returnDisplay).toBe('Analysis complete.');
|
||||
expect(result.returnDisplay).not.toContain('Termination Reason');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Analysis complete.');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
|
||||
it('should show detailed UI for non-goal terminations (e.g., TIMEOUT)', async () => {
|
||||
@@ -220,11 +223,11 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(result.returnDisplay).toContain(
|
||||
'### Subagent MockAgent Finished Early',
|
||||
);
|
||||
expect(result.returnDisplay).toContain('**Termination Reason:** TIMEOUT');
|
||||
expect(result.returnDisplay).toContain('Partial progress...');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Partial progress...');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
|
||||
});
|
||||
|
||||
it('should stream THOUGHT_CHUNK activities from the executor', async () => {
|
||||
@@ -250,8 +253,8 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates
|
||||
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
@@ -283,8 +286,8 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(3);
|
||||
const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress;
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
expect(lastCall.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
@@ -312,7 +315,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
// Execute without the optional callback
|
||||
const result = await invocation.execute(signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.returnDisplay).toBe('Done');
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Done');
|
||||
});
|
||||
|
||||
it('should handle executor run failure', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
@@ -246,28 +245,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
throw cancelError;
|
||||
}
|
||||
|
||||
const displayResult = safeJsonToMarkdown(output.result);
|
||||
const progress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: 'completed',
|
||||
result: output.result,
|
||||
terminateReason: output.terminate_reason,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(progress);
|
||||
}
|
||||
|
||||
const resultContent = `Subagent '${this.definition.name}' finished.
|
||||
Termination Reason: ${output.terminate_reason}
|
||||
Result:
|
||||
${output.result}`;
|
||||
|
||||
const displayContent =
|
||||
output.terminate_reason === AgentTerminateMode.GOAL
|
||||
? displayResult
|
||||
: `
|
||||
### Subagent ${this.definition.name} Finished Early
|
||||
|
||||
**Termination Reason:** ${output.terminate_reason}
|
||||
|
||||
**Result/Summary:**
|
||||
${displayResult}
|
||||
`;
|
||||
|
||||
return {
|
||||
llmContent: [{ text: resultContent }],
|
||||
returnDisplay: displayContent,
|
||||
returnDisplay: progress,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface SubagentProgress {
|
||||
agentName: string;
|
||||
recentActivity: SubagentActivityItem[];
|
||||
state?: 'running' | 'completed' | 'error' | 'cancelled';
|
||||
result?: string;
|
||||
terminateReason?: AgentTerminateMode;
|
||||
}
|
||||
|
||||
export function isSubagentProgress(obj: unknown): obj is SubagentProgress {
|
||||
|
||||
@@ -118,6 +118,7 @@ export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
export * from './utils/cache.js';
|
||||
export * from './utils/markdownUtils.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
|
||||
Reference in New Issue
Block a user