Skip to content

Commit be7c7bb

Browse files
authored
fix(cli): resolve subagent grouping and UI state persistence (google-gemini#22252)
1 parent 7bfe6ac commit be7c7bb

13 files changed

Lines changed: 596 additions & 69 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import { waitFor } from '../../../test-utils/async.js';
7+
import { render } from '../../../test-utils/render.js';
8+
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
9+
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
10+
import type { IndividualToolCallDisplay } from '../../types.js';
11+
import { KeypressProvider } from '../../contexts/KeypressContext.js';
12+
import { OverflowProvider } from '../../contexts/OverflowContext.js';
13+
import { vi } from 'vitest';
14+
import { Text } from 'ink';
15+
16+
vi.mock('../../utils/MarkdownDisplay.js', () => ({
17+
MarkdownDisplay: ({ text }: { text: string }) => <Text>{text}</Text>,
18+
}));
19+
20+
describe('<SubagentGroupDisplay />', () => {
21+
const mockToolCalls: IndividualToolCallDisplay[] = [
22+
{
23+
callId: 'call-1',
24+
name: 'agent_1',
25+
description: 'Test agent 1',
26+
confirmationDetails: undefined,
27+
status: CoreToolCallStatus.Executing,
28+
kind: Kind.Agent,
29+
resultDisplay: {
30+
isSubagentProgress: true,
31+
agentName: 'api-monitor',
32+
state: 'running',
33+
recentActivity: [
34+
{
35+
id: 'act-1',
36+
type: 'tool_call',
37+
status: 'running',
38+
content: '',
39+
displayName: 'Action Required',
40+
description: 'Verify server is running',
41+
},
42+
],
43+
},
44+
},
45+
{
46+
callId: 'call-2',
47+
name: 'agent_2',
48+
description: 'Test agent 2',
49+
confirmationDetails: undefined,
50+
status: CoreToolCallStatus.Success,
51+
kind: Kind.Agent,
52+
resultDisplay: {
53+
isSubagentProgress: true,
54+
agentName: 'db-manager',
55+
state: 'completed',
56+
result: 'Database schema validated',
57+
recentActivity: [
58+
{
59+
id: 'act-2',
60+
type: 'thought',
61+
status: 'completed',
62+
content: 'Database schema validated',
63+
},
64+
],
65+
},
66+
},
67+
];
68+
69+
const renderSubagentGroup = (
70+
toolCallsToRender: IndividualToolCallDisplay[],
71+
height?: number,
72+
) => (
73+
<OverflowProvider>
74+
<KeypressProvider>
75+
<SubagentGroupDisplay
76+
toolCalls={toolCallsToRender}
77+
terminalWidth={80}
78+
availableTerminalHeight={height}
79+
isExpandable={true}
80+
/>
81+
</KeypressProvider>
82+
</OverflowProvider>
83+
);
84+
85+
it('renders nothing if there are no agent tool calls', async () => {
86+
const { lastFrame } = render(renderSubagentGroup([], 40));
87+
expect(lastFrame({ allowEmpty: true })).toBe('');
88+
});
89+
90+
it('renders collapsed view by default with correct agent counts and states', async () => {
91+
const { lastFrame, waitUntilReady } = render(
92+
renderSubagentGroup(mockToolCalls, 40),
93+
);
94+
await waitUntilReady();
95+
expect(lastFrame()).toMatchSnapshot();
96+
});
97+
98+
it('expands when availableTerminalHeight is undefined', async () => {
99+
const { lastFrame, rerender } = render(
100+
renderSubagentGroup(mockToolCalls, 40),
101+
);
102+
103+
// Default collapsed view
104+
await waitFor(() => {
105+
expect(lastFrame()).toContain('(ctrl+o to expand)');
106+
});
107+
108+
// Expand view
109+
rerender(renderSubagentGroup(mockToolCalls, undefined));
110+
await waitFor(() => {
111+
expect(lastFrame()).toContain('(ctrl+o to collapse)');
112+
});
113+
114+
// Collapse view
115+
rerender(renderSubagentGroup(mockToolCalls, 40));
116+
await waitFor(() => {
117+
expect(lastFrame()).toContain('(ctrl+o to expand)');
118+
});
119+
});
120+
});
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type React from 'react';
8+
import { useEffect, useId } from 'react';
9+
import { Box, Text } from 'ink';
10+
import { theme } from '../../semantic-colors.js';
11+
import type { IndividualToolCallDisplay } from '../../types.js';
12+
import {
13+
isSubagentProgress,
14+
checkExhaustive,
15+
type SubagentActivityItem,
16+
} from '@google/gemini-cli-core';
17+
import {
18+
SubagentProgressDisplay,
19+
formatToolArgs,
20+
} from './SubagentProgressDisplay.js';
21+
import { useOverflowActions } from '../../contexts/OverflowContext.js';
22+
23+
export interface SubagentGroupDisplayProps {
24+
toolCalls: IndividualToolCallDisplay[];
25+
availableTerminalHeight?: number;
26+
terminalWidth: number;
27+
borderColor?: string;
28+
borderDimColor?: boolean;
29+
isFirst?: boolean;
30+
isExpandable?: boolean;
31+
}
32+
33+
export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
34+
toolCalls,
35+
availableTerminalHeight,
36+
terminalWidth,
37+
borderColor,
38+
borderDimColor,
39+
isFirst,
40+
isExpandable = true,
41+
}) => {
42+
const isExpanded = availableTerminalHeight === undefined;
43+
const overflowActions = useOverflowActions();
44+
const uniqueId = useId();
45+
const overflowId = `subagent-${uniqueId}`;
46+
47+
useEffect(() => {
48+
if (isExpandable && overflowActions) {
49+
// Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer
50+
// and AppContainer passes the shortcut through.
51+
overflowActions.addOverflowingId(overflowId);
52+
}
53+
return () => {
54+
if (overflowActions) {
55+
overflowActions.removeOverflowingId(overflowId);
56+
}
57+
};
58+
}, [isExpandable, overflowActions, overflowId]);
59+
60+
if (toolCalls.length === 0) {
61+
return null;
62+
}
63+
64+
let headerText = '';
65+
if (toolCalls.length === 1) {
66+
const singleAgent = toolCalls[0].resultDisplay;
67+
if (isSubagentProgress(singleAgent)) {
68+
switch (singleAgent.state) {
69+
case 'completed':
70+
headerText = 'Agent Completed';
71+
break;
72+
case 'cancelled':
73+
headerText = 'Agent Cancelled';
74+
break;
75+
case 'error':
76+
headerText = 'Agent Error';
77+
break;
78+
default:
79+
headerText = 'Running Agent...';
80+
break;
81+
}
82+
} else {
83+
headerText = 'Running Agent...';
84+
}
85+
} else {
86+
let completedCount = 0;
87+
let runningCount = 0;
88+
for (const tc of toolCalls) {
89+
const progress = tc.resultDisplay;
90+
if (isSubagentProgress(progress)) {
91+
if (progress.state === 'completed') completedCount++;
92+
else if (progress.state === 'running') runningCount++;
93+
} else {
94+
// It hasn't emitted progress yet, but it is "running"
95+
runningCount++;
96+
}
97+
}
98+
99+
if (completedCount === toolCalls.length) {
100+
headerText = `${toolCalls.length} Agents Completed`;
101+
} else if (completedCount > 0) {
102+
headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`;
103+
} else {
104+
headerText = `Running ${toolCalls.length} Agents...`;
105+
}
106+
}
107+
const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`;
108+
109+
const renderCollapsedRow = (
110+
key: string,
111+
agentName: string,
112+
icon: React.ReactNode,
113+
content: string,
114+
displayArgs?: string,
115+
) => (
116+
<Box key={key} flexDirection="row" marginLeft={0} marginTop={0}>
117+
<Box minWidth={2} flexShrink={0}>
118+
{icon}
119+
</Box>
120+
<Box flexShrink={0}>
121+
<Text bold color={theme.text.primary} wrap="truncate">
122+
{agentName}
123+
</Text>
124+
</Box>
125+
<Box flexShrink={0}>
126+
<Text color={theme.text.secondary}> · </Text>
127+
</Box>
128+
<Box flexShrink={1} minWidth={0}>
129+
<Text color={theme.text.secondary} wrap="truncate">
130+
{content}
131+
{displayArgs && ` ${displayArgs}`}
132+
</Text>
133+
</Box>
134+
</Box>
135+
);
136+
137+
return (
138+
<Box
139+
flexDirection="column"
140+
width={terminalWidth}
141+
borderLeft={true}
142+
borderRight={true}
143+
borderTop={isFirst}
144+
borderBottom={false}
145+
borderColor={borderColor}
146+
borderDimColor={borderDimColor}
147+
borderStyle="round"
148+
paddingLeft={1}
149+
paddingTop={0}
150+
paddingBottom={0}
151+
>
152+
<Box flexDirection="row" gap={1} marginBottom={isExpanded ? 1 : 0}>
153+
<Text color={theme.text.secondary}></Text>
154+
<Text bold color={theme.text.primary}>
155+
{headerText}
156+
</Text>
157+
{isExpandable && <Text color={theme.text.secondary}>{toggleText}</Text>}
158+
</Box>
159+
160+
{toolCalls.map((toolCall) => {
161+
const progress = toolCall.resultDisplay;
162+
163+
if (!isSubagentProgress(progress)) {
164+
const agentName = toolCall.name || 'agent';
165+
if (!isExpanded) {
166+
return renderCollapsedRow(
167+
toolCall.callId,
168+
agentName,
169+
<Text color={theme.text.primary}>!</Text>,
170+
'Starting...',
171+
);
172+
} else {
173+
return (
174+
<Box
175+
key={toolCall.callId}
176+
flexDirection="column"
177+
marginLeft={0}
178+
marginBottom={1}
179+
>
180+
<Box flexDirection="row" gap={1}>
181+
<Text color={theme.text.primary}>!</Text>
182+
<Text bold color={theme.text.primary}>
183+
{agentName}
184+
</Text>
185+
</Box>
186+
<Box marginLeft={2}>
187+
<Text color={theme.text.secondary}>Starting...</Text>
188+
</Box>
189+
</Box>
190+
);
191+
}
192+
}
193+
194+
const lastActivity: SubagentActivityItem | undefined =
195+
progress.recentActivity[progress.recentActivity.length - 1];
196+
197+
// Collapsed View: Show single compact line per agent
198+
if (!isExpanded) {
199+
let content = 'Starting...';
200+
let formattedArgs: string | undefined;
201+
202+
if (progress.state === 'completed') {
203+
if (
204+
progress.terminateReason &&
205+
progress.terminateReason !== 'GOAL'
206+
) {
207+
content = `Finished Early (${progress.terminateReason})`;
208+
} else {
209+
content = 'Completed successfully';
210+
}
211+
} else if (lastActivity) {
212+
// Match expanded view logic exactly:
213+
// Primary text: displayName || content
214+
content = lastActivity.displayName || lastActivity.content;
215+
216+
// Secondary text: description || formatToolArgs(args)
217+
if (lastActivity.description) {
218+
formattedArgs = lastActivity.description;
219+
} else if (lastActivity.type === 'tool_call' && lastActivity.args) {
220+
formattedArgs = formatToolArgs(lastActivity.args);
221+
}
222+
}
223+
224+
const displayArgs =
225+
progress.state === 'completed' ? '' : formattedArgs;
226+
227+
const renderStatusIcon = () => {
228+
const state = progress.state ?? 'running';
229+
switch (state) {
230+
case 'running':
231+
return <Text color={theme.text.primary}>!</Text>;
232+
case 'completed':
233+
return <Text color={theme.status.success}></Text>;
234+
case 'cancelled':
235+
return <Text color={theme.status.warning}></Text>;
236+
case 'error':
237+
return <Text color={theme.status.error}></Text>;
238+
default:
239+
return checkExhaustive(state);
240+
}
241+
};
242+
243+
return renderCollapsedRow(
244+
toolCall.callId,
245+
progress.agentName,
246+
renderStatusIcon(),
247+
lastActivity?.type === 'thought' ? `💭 ${content}` : content,
248+
displayArgs,
249+
);
250+
}
251+
252+
// Expanded View: Render full history
253+
return (
254+
<Box
255+
key={toolCall.callId}
256+
flexDirection="column"
257+
marginLeft={0}
258+
marginBottom={1}
259+
>
260+
<SubagentProgressDisplay
261+
progress={progress}
262+
terminalWidth={terminalWidth}
263+
/>
264+
</Box>
265+
);
266+
})}
267+
</Box>
268+
);
269+
};

0 commit comments

Comments
 (0)