Skip to content
7 changes: 5 additions & 2 deletions packages/a2a-server/src/agent/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
type ToolCallConfirmationDetails,
type Config,
type UserTierId,
type AnsiOutput,
type ToolLiveOutput,
isSubagentProgress,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
Expand Down Expand Up @@ -333,11 +334,13 @@ export class Task {

private _schedulerOutputUpdate(
toolCallId: string,
outputChunk: string | AnsiOutput,
outputChunk: ToolLiveOutput,
): void {
let outputAsText: string;
if (typeof outputChunk === 'string') {
outputAsText = outputChunk;
} else if (isSubagentProgress(outputChunk)) {
outputAsText = JSON.stringify(outputChunk);
} else {
outputAsText = outputChunk
.map((line) => line.map((token) => token.text).join(''))
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/components/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ export const MainContent = () => {

const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;

const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);

useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingTool]);
}, [showConfirmationQueue, confirmingToolCallId]);

const {
pendingHistoryItems,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
151 changes: 151 additions & 0 deletions packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
}),
),
[allToolCalls],
Expand Down
Loading
Loading