Subagent activity UX. (#17570)

This commit is contained in:
Christian Gunderman
2026-03-02 21:04:31 +00:00
committed by GitHub
parent ce5a2d0760
commit 7ca3a33f8b
25 changed files with 827 additions and 88 deletions

View File

@@ -34,6 +34,7 @@ export const MainContent = () => {
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
@@ -41,7 +42,7 @@ export const MainContent = () => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingTool]);
}, [showConfirmationQueue, confirmingToolCallId]);
const {
pendingHistoryItems,

View File

@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render, cleanup } from '../../../test-utils/render.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Text } from 'ink';
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
}));
describe('<SubagentProgressDisplay />', () => {
afterEach(() => {
vi.restoreAllMocks();
cleanup();
});
it('renders correctly with description in args', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '1',
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello", "description": "Say hello"}',
status: 'running',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with displayName and description from item', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '1',
type: 'tool_call',
content: 'run_shell_command',
displayName: 'RunShellCommand',
description: 'Executing echo hello',
args: '{"command": "echo hello"}',
status: 'running',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with command fallback', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '2',
type: 'tool_call',
content: 'run_shell_command',
args: '{"command": "echo hello"}',
status: 'running',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with file_path', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '3',
type: 'tool_call',
content: 'write_file',
args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
status: 'completed',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long args', async () => {
const longDesc =
'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.';
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '4',
type: 'tool_call',
content: 'run_shell_command',
args: JSON.stringify({ description: longDesc }),
status: 'running',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders thought bubbles correctly', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '5',
type: 'thought',
content: 'Thinking about life',
status: 'running',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders cancelled state correctly', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [],
state: 'cancelled',
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders "Request cancelled." with the info icon', async () => {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'TestAgent',
recentActivity: [
{
id: '6',
type: 'thought',
content: 'Request cancelled.',
status: 'error',
},
],
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import Spinner from 'ink-spinner';
import type {
SubagentProgress,
SubagentActivityItem,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
export interface SubagentProgressDisplayProps {
progress: SubagentProgress;
}
const formatToolArgs = (args?: string): string => {
if (!args) return '';
try {
const parsed: unknown = JSON.parse(args);
if (typeof parsed !== 'object' || parsed === null) {
return args;
}
if (
'description' in parsed &&
typeof parsed.description === 'string' &&
parsed.description
) {
return parsed.description;
}
if ('command' in parsed && typeof parsed.command === 'string')
return parsed.command;
if ('file_path' in parsed && typeof parsed.file_path === 'string')
return parsed.file_path;
if ('dir_path' in parsed && typeof parsed.dir_path === 'string')
return parsed.dir_path;
if ('query' in parsed && typeof parsed.query === 'string')
return parsed.query;
if ('url' in parsed && typeof parsed.url === 'string') return parsed.url;
if ('target' in parsed && typeof parsed.target === 'string')
return parsed.target;
return args;
} catch {
return args;
}
};
export const SubagentProgressDisplay: React.FC<
SubagentProgressDisplayProps
> = ({ progress }) => {
let headerText: string | undefined;
let headerColor = theme.text.secondary;
if (progress.state === 'cancelled') {
headerText = `Subagent ${progress.agentName} was cancelled.`;
headerColor = theme.status.warning;
} else if (progress.state === 'error') {
headerText = `Subagent ${progress.agentName} failed.`;
headerColor = theme.status.error;
} else if (progress.state === 'completed') {
headerText = `Subagent ${progress.agentName} completed.`;
headerColor = theme.status.success;
}
return (
<Box flexDirection="column" paddingY={0}>
{headerText && (
<Box marginBottom={1}>
<Text color={headerColor} italic>
{headerText}
</Text>
</Box>
)}
<Box flexDirection="column" marginLeft={0} gap={0}>
{progress.recentActivity.map((item: SubagentActivityItem) => {
if (item.type === 'thought') {
const isCancellation = item.content === 'Request cancelled.';
const icon = isCancellation ? ' ' : '💭';
const color = isCancellation
? theme.status.warning
: theme.text.secondary;
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>
<Text color={color}>{icon}</Text>
</Box>
<Box flexGrow={1}>
<Text color={color}>{item.content}</Text>
</Box>
</Box>
);
} else if (item.type === 'tool_call') {
const statusSymbol =
item.status === 'running' ? (
<Spinner type="dots" />
) : item.status === 'completed' ? (
<Text color={theme.status.success}>{TOOL_STATUS.SUCCESS}</Text>
) : item.status === 'cancelled' ? (
<Text color={theme.status.warning} bold>
{TOOL_STATUS.CANCELED}
</Text>
) : (
<Text color={theme.status.error}>{TOOL_STATUS.ERROR}</Text>
);
const formattedArgs = item.description || formatToolArgs(item.args);
const displayArgs =
formattedArgs.length > 60
? formattedArgs.slice(0, 60) + '...'
: formattedArgs;
return (
<Box key={item.id} flexDirection="row">
<Box minWidth={STATUS_INDICATOR_WIDTH}>{statusSymbol}</Box>
<Box flexDirection="row" flexGrow={1} flexWrap="wrap">
<Text
bold
color={theme.text.primary}
strikethrough={item.status === 'cancelled'}
>
{item.displayName || item.content}
</Text>
{displayArgs && (
<Box marginLeft={1}>
<Text
color={theme.text.secondary}
wrap="truncate"
strikethrough={item.status === 'cancelled'}
>
{displayArgs}
</Text>
</Box>
)}
</Box>
</Box>
);
}
return null;
})}
</Box>
</Box>
);
};

View File

@@ -75,6 +75,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
});
}),
[allToolCalls, isLowErrorVerbosity],

View File

@@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -20,6 +24,7 @@ import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
@@ -167,6 +172,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
{formattedJSON}
</Text>
);
} else if (isSubagentProgress(truncatedResultDisplay)) {
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
} else if (
typeof truncatedResultDisplay === 'string' &&
renderOutputAsMarkdown

View File

@@ -0,0 +1,41 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
" Request cancelled.
"
`;
exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
"Subagent TestAgent was cancelled.
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
"⠋ run_shell_command echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
"⠋ run_shell_command Say hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
"⠋ RunShellCommand Executing echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
"✓ write_file /tmp/test.txt
"
`;
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
"💭 Thinking about life
"
`;
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
"⠋ run_shell_command This is a very long description that should definitely be tr...
"
`;

View File

@@ -48,6 +48,7 @@ export function mapToDisplay(
const baseDisplayProperties = {
callId: call.request.callId,
parentCallId: call.request.parentCallId,
name: displayName,
description,
renderOutputAsMarkdown,

View File

@@ -98,6 +98,7 @@ export interface ToolCallEvent {
export interface IndividualToolCallDisplay {
callId: string;
parentCallId?: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;