feat(cli): refactor tool rendering to declarative ToolDisplay system

This change completes the transition of the interactive agent session (`useAgentStream`)
to a declarative-first tool rendering system.

Key changes:
- Reverted experimental `ToolDisplay` logic from legacy UI components (`DenseToolMessage`, etc.)
  to establish a clean baseline.
- Introduced `HistoryItemToolDisplayGroup` and `ToolGroupDisplay` component in CLI.
- Added `display` property to `ToolCallRequestInfo` to carry declarative UI info natively.
- Populated tool request display information at the source (`Turn.ts` and `Scheduler.ts`)
  using dynamic descriptions from tool invocations.
- Updated `useAgentStream` to emit the new history item type, providing a standalone
  rendering path for interactive sessions.
- Ensured tool descriptions are updated when arguments are modified during confirmation.
This commit is contained in:
Michael Bleigh
2026-04-12 11:46:18 -07:00
parent 410e675837
commit 45eababfd8
10 changed files with 262 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
@@ -208,7 +209,12 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isToolGroupBoundary={isToolGroupBoundary}
/>
)}
{/* TODO: tool_group_display goes here */}
{itemForDisplay.type === 'tool_display_group' && (
<ToolGroupDisplay
item={itemForDisplay}
isToolGroupBoundary={isToolGroupBoundary}
/>
)}
{itemForDisplay.type === 'subagent' && (
<SubagentHistoryMessage
item={itemForDisplay}

View File

@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import type {
HistoryItem,
HistoryItemWithoutId,
HistoryItemToolDisplayGroup,
ToolDisplayItem,
} from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { ToolStatusIndicator } from './ToolShared.js';
import { useSettings } from '../../contexts/SettingsContext.js';
interface ToolGroupDisplayProps {
item: HistoryItem | HistoryItemWithoutId;
isToolGroupBoundary?: boolean;
}
export const ToolGroupDisplay: React.FC<ToolGroupDisplayProps> = ({
item,
isToolGroupBoundary,
}) => {
if (item.type !== 'tool_display_group') {
return null;
}
const { tools, borderColor, borderDimColor, borderTop, borderBottom } =
item as HistoryItemToolDisplayGroup;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
borderDimColor={borderDimColor}
borderTop={borderTop}
borderBottom={borderBottom}
borderLeft={!isToolGroupBoundary}
borderRight={!isToolGroupBoundary}
marginTop={borderTop ? 1 : 0}
marginBottom={borderBottom ? 1 : 0}
paddingX={1}
>
{tools.map((tool, index) => (
<ToolDisplayMessage key={index} tool={tool} />
))}
</Box>
);
};
interface ToolDisplayMessageProps {
tool: ToolDisplayItem;
}
const ToolDisplayMessage: React.FC<ToolDisplayMessageProps> = ({ tool }) => {
const settings = useSettings();
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
// Since ToolDisplayItem is ToolDisplay & { status, ... }, we check for identifying properties
// of ToolDisplay. If name or description is missing and there's no result, it might be "empty".
// But per instructions, if display is missing (which we now interpret as the ToolDisplay part being effectively empty/null), show error.
if (!tool.name && !tool.description && !tool.result && !tool.resultSummary) {
return (
<Box paddingLeft={2}>
<ToolStatusIndicator
status={tool.status}
name={tool.originalRequestName || 'unknown'}
/>
<Text color={theme.status.error}> Error: Tool display missing</Text>
</Box>
);
}
const {
status,
format: preferredFormat,
name,
description,
resultSummary,
result,
} = tool;
const format = preferredFormat || 'auto';
if (format === 'hidden') {
return null;
}
const isCompact =
format === 'compact' || (format === 'auto' && isCompactModeEnabled);
if (isCompact) {
return (
<Box paddingLeft={2} flexDirection="row" flexWrap="wrap">
<ToolStatusIndicator
status={status}
name={name || tool.originalRequestName || ''}
/>
<Text bold color={theme.text.primary}>
{' '}
{name || tool.originalRequestName}{' '}
</Text>
{description && <Text color={theme.text.secondary}>{description}</Text>}
{resultSummary && (
<Text color={theme.text.accent}> {resultSummary}</Text>
)}
</Box>
);
}
// Box format (full)
return (
<Box flexDirection="column" paddingLeft={2} marginBottom={1}>
<Box flexDirection="row">
<ToolStatusIndicator
status={status}
name={name || tool.originalRequestName || ''}
/>
<Text bold color={theme.text.primary}>
{' '}
{name || tool.originalRequestName}{' '}
</Text>
{description && <Text color={theme.text.secondary}>{description}</Text>}
</Box>
{resultSummary && !result && (
<Box paddingLeft={2}>
<Text color={theme.text.accent}> {resultSummary}</Text>
</Box>
)}
{result && (
<Box paddingLeft={2} marginTop={0}>
<ToolResultDisplayContent content={result} summary={resultSummary} />
</Box>
)}
</Box>
);
};
interface ToolResultDisplayContentProps {
content: ToolDisplayItem['result'];
summary?: string | null;
}
const ToolResultDisplayContent: React.FC<ToolResultDisplayContentProps> = ({
content,
summary,
}) => {
if (!content) return null;
switch (content.type) {
case 'text':
return <Text color={theme.text.secondary}>{content.text}</Text>;
case 'diff':
// Simplified diff display for now
return (
<Box flexDirection="column">
{summary && <Text color={theme.text.accent}>{summary}</Text>}
<Text color={theme.text.secondary}>
{`[Diff Display: ${content.beforeText.length} -> ${content.afterText.length} chars]`}
</Text>
</Box>
);
case 'terminal':
return <Text color={theme.text.secondary}>[Terminal Output]</Text>;
case 'agent':
return (
<Text color={theme.text.secondary}>[Subagent: {content.threadId}]</Text>
);
default:
return null;
}
};

View File

@@ -26,7 +26,7 @@ import type {
HistoryItemWithoutId,
LoopDetectionConfirmationRequest,
IndividualToolCallDisplay,
HistoryItemToolGroup,
HistoryItemToolDisplayGroup,
} from '../types.js';
import { StreamingState, MessageType } from '../types.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
@@ -415,9 +415,15 @@ export const useAgentStream = ({
backgroundTasks,
);
const historyItem: HistoryItemToolGroup = {
type: 'tool_group',
tools: toolsToPush,
const historyItem: HistoryItemToolDisplayGroup = {
type: 'tool_display_group',
tools: toolsToPush.map((tc) => ({
name: tc.name,
description: tc.description,
...tc.display,
status: tc.status,
originalRequestName: tc.originalRequestName,
})),
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...appearance,
@@ -456,8 +462,14 @@ export const useAgentStream = ({
if (remainingTools.length > 0) {
items.push({
type: 'tool_group',
tools: remainingTools,
type: 'tool_display_group',
tools: remainingTools.map((tc) => ({
name: tc.name,
description: tc.description,
...tc.display,
status: tc.status,
originalRequestName: tc.originalRequestName,
})),
borderTop: pushedToolCallIds.size === 0,
borderBottom: false,
...appearance,
@@ -486,7 +498,7 @@ export const useAgentStream = ({
(anyVisibleInHistory || anyVisibleInPending)
) {
items.push({
type: 'tool_group' as const,
type: 'tool_display_group',
tools: [],
borderTop: false,
borderBottom: true,

View File

@@ -260,9 +260,18 @@ export type HistoryItemToolGroup = HistoryItemBase & {
borderDimColor?: boolean;
};
export type ToolDisplayItem = ToolDisplay & {
status: CoreToolCallStatus;
originalRequestName?: string;
};
export type HistoryItemToolDisplayGroup = HistoryItemBase & {
type: 'tool_display_group';
tools: ToolDisplay[];
tools: ToolDisplayItem[];
borderTop?: boolean;
borderBottom?: boolean;
borderColor?: string;
borderDimColor?: boolean;
};
export type HistoryItemUserShell = HistoryItemBase & {