feat: add emphasis to tool confirmations (#502)

This commit is contained in:
Brandon Keiji 2025-05-23 05:28:31 +00:00 committed by GitHub
parent 1d0856dcc8
commit 01971741e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 107 deletions

1
package-lock.json generated
View File

@ -10403,6 +10403,7 @@
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"fast-glob": "^3.3.3",
"shell-quote": "^1.8.2",
"sqlite3": "^5.1.7"
},
"devDependencies": {

View File

@ -27,9 +27,9 @@
"bundle": "esbuild packages/cli/index.ts --bundle --outfile=bundle/gemini.js --platform=node --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);\" && bash scripts/copy_bundle_assets.sh"
},
"devDependencies": {
"@types/mime-types": "^2.1.4",
"@vitest/coverage-v8": "^3.1.1",
"esbuild": "^0.25.4",
"@types/mime-types": "^2.1.4",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0",

View File

@ -29,6 +29,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
@ -50,27 +52,38 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderColor={borderColor}
marginBottom={1}
>
{toolCalls.map((tool) => (
<Box key={tool.callId} flexDirection="column">
<ToolMessage
key={tool.callId}
callId={tool.callId}
name={tool.name}
description={tool.description}
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
availableTerminalHeight={availableTerminalHeight - staticHeight}
/>
{tool.status === ToolCallStatus.Confirming &&
tool.callId === toolAwaitingApproval?.callId &&
tool.confirmationDetails && (
<ToolConfirmationMessage
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
return (
<Box key={tool.callId} flexDirection="column">
<Box flexDirection="row" alignItems="center">
<ToolMessage
callId={tool.callId}
name={tool.name}
description={tool.description}
resultDisplay={tool.resultDisplay}
status={tool.status}
confirmationDetails={tool.confirmationDetails}
></ToolConfirmationMessage>
)}
</Box>
))}
availableTerminalHeight={availableTerminalHeight - staticHeight}
emphasis={
isConfirming
? 'high'
: toolAwaitingApproval
? 'low'
: 'medium'
}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
/>
)}
</Box>
);
})}
</Box>
);
};

View File

@ -12,8 +12,15 @@ import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const STATUS_INDICATOR_WIDTH = 3;
export type TextEmphasis = 'high' | 'medium' | 'low';
export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight: number;
emphasis?: TextEmphasis;
}
export const ToolMessage: React.FC<ToolMessageProps> = ({
@ -22,63 +29,45 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
resultDisplay,
status,
availableTerminalHeight,
emphasis = 'medium',
}) => {
const statusIndicatorWidth = 3;
const hasResult = resultDisplay && resultDisplay.toString().trim().length > 0;
const staticHeight = /* Header */ 1;
let displayableResult = resultDisplay;
let hiddenLines = 0;
const contentHeightEstimate =
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT;
const resultIsString =
typeof resultDisplay === 'string' && resultDisplay.trim().length > 0;
const lines = React.useMemo(
() => (resultIsString ? resultDisplay.split('\n') : []),
[resultIsString, resultDisplay],
);
// Truncate the overall string content if it's too long.
// MarkdownRenderer will handle specific truncation for code blocks within this content.
if (typeof resultDisplay === 'string' && resultDisplay.length > 0) {
const lines = resultDisplay.split('\n');
// Estimate available height for this specific tool message content area
// This is a rough estimate; ideally, we'd have a more precise measurement.
const contentHeightEstimate = availableTerminalHeight - staticHeight - 5; // Subtracting lines for tool name, status, padding etc.
if (lines.length > contentHeightEstimate && contentHeightEstimate > 0) {
displayableResult = lines.slice(0, contentHeightEstimate).join('\n');
hiddenLines = lines.length - contentHeightEstimate;
}
}
// Estimate available height for this specific tool message content area
// This is a rough estimate; ideally, we'd have a more precise measurement.
const displayableResult = React.useMemo(
() =>
resultIsString
? lines.slice(0, contentHeightEstimate).join('\n')
: resultDisplay,
[lines, resultIsString, contentHeightEstimate, resultDisplay],
);
const hiddenLines = lines.length - contentHeightEstimate;
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
{/* Status Indicator */}
<Box minWidth={statusIndicatorWidth}>
{(status === ToolCallStatus.Pending ||
status === ToolCallStatus.Executing) && <Spinner type="dots" />}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
</Text>
)}
</Box>
<Box>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text bold>{name}</Text>{' '}
<Text color={Colors.SubtleComment}>{description}</Text>
</Text>
</Box>
<ToolStatusIndicator status={status} />
<ToolInfo
name={name}
status={status}
description={description}
emphasis={emphasis}
/>
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{hasResult && (
<Box paddingLeft={statusIndicatorWidth} width="100%">
{displayableResult && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%">
<Box flexDirection="column">
{typeof displayableResult === 'string' && (
<Box flexDirection="column">
@ -89,7 +78,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/>
</Box>
)}
{typeof displayableResult === 'object' && (
{typeof displayableResult !== 'string' && (
<DiffRenderer
diffContent={displayableResult.fileDiff}
filename={displayableResult.fileName}
@ -109,3 +98,76 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
</Box>
);
};
type ToolStatusIndicator = {
status: ToolCallStatus;
};
const ToolStatusIndicator: React.FC<ToolStatusIndicator> = ({ status }) => (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={Colors.AccentGreen}>o</Text>
)}
{status === ToolCallStatus.Executing && <Spinner type="dots" />}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
</Text>
)}
</Box>
);
type ToolInfo = {
name: string;
description: string;
status: ToolCallStatus;
emphasis: TextEmphasis;
};
const ToolInfo: React.FC<ToolInfo> = ({
name,
description,
status,
emphasis,
}) => {
const nameColor = React.useMemo<string>(() => {
switch (emphasis) {
case 'high':
return Colors.Foreground;
case 'medium':
return Colors.Foreground;
case 'low':
return Colors.SubtleComment;
default: {
const exhaustiveCheck: never = emphasis;
return exhaustiveCheck;
}
}
}, [emphasis]);
return (
<Box>
<Text
wrap="truncate-end"
strikethrough={status === ToolCallStatus.Canceled}
>
<Text color={nameColor} bold>
{name}
</Text>{' '}
<Text color={Colors.SubtleComment}>{description}</Text>
</Text>
</Box>
);
};
const TrailingIndicator: React.FC = () => (
<Text color={Colors.Foreground}> </Text>
);

View File

@ -184,48 +184,53 @@ export function useToolScheduler(
useEffect(() => {
// effect for executing scheduled tool calls
if (toolCalls.every((t) => t.status === 'scheduled')) {
const allToolsConfirmed = toolCalls.every(
(t) => t.status === 'scheduled' || t.status === 'cancelled',
);
if (allToolsConfirmed) {
const signal = abortController.signal;
toolCalls.forEach((c) => {
const callId = c.request.callId;
setToolCalls(setStatus(c.request.callId, 'executing'));
c.tool
.execute(c.request.args, signal)
.then((result) => {
if (signal.aborted) {
setToolCalls(
setStatus(callId, 'cancelled', 'Cancelled during execution'),
);
return;
}
const functionResponse: Part = {
functionResponse: {
name: c.request.name,
id: callId,
response: { output: result.llmContent },
},
};
const response: ToolCallResponseInfo = {
callId,
responsePart: functionResponse,
resultDisplay: result.returnDisplay,
error: undefined,
};
setToolCalls(setStatus(callId, 'success', response));
})
.catch((e) =>
setToolCalls(
setStatus(
toolCalls
.filter((t) => t.status === 'scheduled')
.forEach((t) => {
const callId = t.request.callId;
setToolCalls(setStatus(t.request.callId, 'executing'));
t.tool
.execute(t.request.args, signal)
.then((result) => {
if (signal.aborted) {
setToolCalls(
setStatus(callId, 'cancelled', 'Cancelled during execution'),
);
return;
}
const functionResponse: Part = {
functionResponse: {
name: t.request.name,
id: callId,
response: { output: result.llmContent },
},
};
const response: ToolCallResponseInfo = {
callId,
'error',
toolErrorResponse(
c.request,
e instanceof Error ? e : new Error(String(e)),
responsePart: functionResponse,
resultDisplay: result.returnDisplay,
error: undefined,
};
setToolCalls(setStatus(callId, 'success', response));
})
.catch((e) =>
setToolCalls(
setStatus(
callId,
'error',
toolErrorResponse(
t.request,
e instanceof Error ? e : new Error(String(e)),
),
),
),
),
);
});
);
});
}
}, [toolCalls, toolRegistry, abortController.signal]);