Merge branch 'main' into fix/headless-log

This commit is contained in:
cynthialong0-0
2026-03-18 07:45:02 -07:00
committed by GitHub
41 changed files with 1155 additions and 237 deletions
@@ -15,6 +15,7 @@ describe('<ChecklistItem />', () => {
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
{ status: 'blocked', label: 'Blocked this' },
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
@@ -13,7 +13,8 @@ export type ChecklistStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled';
| 'cancelled'
| 'blocked';
export interface ChecklistItemData {
status: ChecklistStatus;
@@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
</Text>
);
case 'blocked':
return (
<Text color={theme.status.warning} aria-label="Blocked">
</Text>
);
default:
checkExhaustive(status);
}
@@ -70,6 +77,7 @@ export const ChecklistItem: React.FC<ChecklistItemProps> = ({
return theme.text.accent;
case 'completed':
case 'cancelled':
case 'blocked':
return theme.text.secondary;
case 'pending':
return theme.text.primary;
@@ -110,78 +110,17 @@ const SESSIONS_PER_PAGE = 20;
// If the SessionItem layout changes, update this accordingly.
const FIXED_SESSION_COLUMNS_WIDTH = 30;
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowser/SessionBrowserNav.js';
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
*/
const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Table header component with column labels and scroll indicators.
*/
@@ -219,21 +158,6 @@ const SessionTableHeader = ({
</Box>
);
/**
* No results display component for empty search results.
*/
const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
/**
* Match snippet display component for search results.
*/
@@ -398,7 +322,7 @@ const SessionList = ({
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelp />}
{!state.isSearchMode && <NavigationHelpDisplay />}
<SessionTableHeader state={state} />
</Box>
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
export const NavigationHelpDisplay = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Search input display component.
*/
export const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* No results display component for empty search results.
*/
export const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowserNav.js';
import { SessionListHeader } from './SessionListHeader.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser Search and Navigation Components', () => {
it('SearchModeDisplay renders correctly with query', async () => {
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SearchModeDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NavigationHelp renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<NavigationHelpDisplay />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly', async () => {
const mockState = {
totalSessions: 10,
searchQuery: '',
sortOrder: 'date',
sortReverse: false,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly with filter', async () => {
const mockState = {
totalSessions: 5,
searchQuery: 'test',
sortOrder: 'name',
sortReverse: true,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NoResultsDisplay renders correctly', async () => {
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<NoResultsDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Header component showing session count and sort information.
*/
export const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
"
`;
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
"
No sessions found matching 'no match'.
"
`;
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
"
Search: test query (Esc to cancel)
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
"Chat Sessions (10 total) sorted by date desc
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
"Chat Sessions (5 total, filtered) sorted by name asc
"
`;
@@ -1,5 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChecklistItem /> > renders { status: 'blocked', label: 'Blocked this' } item correctly 1`] = `
"⛔ Blocked this
"
`;
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `
"✗ Skipped this
"
@@ -42,33 +42,19 @@ export interface ShellToolMessageProps extends ToolMessageProps {
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
name,
description,
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
ptyId,
config,
isFirst,
borderColor,
borderDimColor,
isExpandable,
originalRequestName,
}) => {
const {
@@ -142,11 +128,9 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const headerRef = React.useRef<DOMElement>(null);
const contentRef = React.useRef<DOMElement>(null);
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
const handleFocus = () => {
@@ -156,7 +140,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
};
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const { shouldShowFocusHint } = useFocusHint(
@@ -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)');
});
});
});
@@ -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...
"
`;
+53 -17
View File
@@ -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);
}