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", "diff": "^7.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"shell-quote": "^1.8.2",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "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" "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": { "devDependencies": {
"@types/mime-types": "^2.1.4",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"esbuild": "^0.25.4", "esbuild": "^0.25.4",
"@types/mime-types": "^2.1.4",
"eslint": "^9.24.0", "eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2", "eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",

View File

@ -29,6 +29,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2 + /* marginBottom */ 1; 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( const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming), () => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls], [toolCalls],
@ -50,27 +52,38 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderColor={borderColor} borderColor={borderColor}
marginBottom={1} marginBottom={1}
> >
{toolCalls.map((tool) => ( {toolCalls.map((tool) => {
<Box key={tool.callId} flexDirection="column"> const isConfirming = toolAwaitingApproval?.callId === tool.callId;
<ToolMessage return (
key={tool.callId} <Box key={tool.callId} flexDirection="column">
callId={tool.callId} <Box flexDirection="row" alignItems="center">
name={tool.name} <ToolMessage
description={tool.description} callId={tool.callId}
resultDisplay={tool.resultDisplay} name={tool.name}
status={tool.status} description={tool.description}
confirmationDetails={tool.confirmationDetails} resultDisplay={tool.resultDisplay}
availableTerminalHeight={availableTerminalHeight - staticHeight} status={tool.status}
/>
{tool.status === ToolCallStatus.Confirming &&
tool.callId === toolAwaitingApproval?.callId &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
></ToolConfirmationMessage> availableTerminalHeight={availableTerminalHeight - staticHeight}
)} emphasis={
</Box> isConfirming
))} ? 'high'
: toolAwaitingApproval
? 'low'
: 'medium'
}
/>
</Box>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
confirmationDetails={tool.confirmationDetails}
/>
)}
</Box>
);
})}
</Box> </Box>
); );
}; };

View File

@ -12,8 +12,15 @@ import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js'; import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.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 { export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight: number; availableTerminalHeight: number;
emphasis?: TextEmphasis;
} }
export const ToolMessage: React.FC<ToolMessageProps> = ({ export const ToolMessage: React.FC<ToolMessageProps> = ({
@ -22,63 +29,45 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
resultDisplay, resultDisplay,
status, status,
availableTerminalHeight, availableTerminalHeight,
emphasis = 'medium',
}) => { }) => {
const statusIndicatorWidth = 3; const contentHeightEstimate =
const hasResult = resultDisplay && resultDisplay.toString().trim().length > 0; availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT;
const staticHeight = /* Header */ 1; const resultIsString =
typeof resultDisplay === 'string' && resultDisplay.trim().length > 0;
let displayableResult = resultDisplay; const lines = React.useMemo(
let hiddenLines = 0; () => (resultIsString ? resultDisplay.split('\n') : []),
[resultIsString, resultDisplay],
);
// Truncate the overall string content if it's too long. // Truncate the overall string content if it's too long.
// MarkdownRenderer will handle specific truncation for code blocks within this content. // MarkdownRenderer will handle specific truncation for code blocks within this content.
if (typeof resultDisplay === 'string' && resultDisplay.length > 0) { // Estimate available height for this specific tool message content area
const lines = resultDisplay.split('\n'); // This is a rough estimate; ideally, we'd have a more precise measurement.
// Estimate available height for this specific tool message content area const displayableResult = React.useMemo(
// 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. resultIsString
if (lines.length > contentHeightEstimate && contentHeightEstimate > 0) { ? lines.slice(0, contentHeightEstimate).join('\n')
displayableResult = lines.slice(0, contentHeightEstimate).join('\n'); : resultDisplay,
hiddenLines = lines.length - contentHeightEstimate; [lines, resultIsString, contentHeightEstimate, resultDisplay],
} );
} const hiddenLines = lines.length - contentHeightEstimate;
return ( return (
<Box paddingX={1} paddingY={0} flexDirection="column"> <Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}> <Box minHeight={1}>
{/* Status Indicator */} {/* Status Indicator */}
<Box minWidth={statusIndicatorWidth}> <ToolStatusIndicator status={status} />
{(status === ToolCallStatus.Pending || <ToolInfo
status === ToolCallStatus.Executing) && <Spinner type="dots" />} name={name}
{status === ToolCallStatus.Success && ( status={status}
<Text color={Colors.AccentGreen}></Text> description={description}
)} emphasis={emphasis}
{status === ToolCallStatus.Confirming && ( />
<Text color={Colors.AccentYellow}>?</Text> {emphasis === 'high' && <TrailingIndicator />}
)}
{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>
</Box> </Box>
{hasResult && ( {displayableResult && (
<Box paddingLeft={statusIndicatorWidth} width="100%"> <Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%">
<Box flexDirection="column"> <Box flexDirection="column">
{typeof displayableResult === 'string' && ( {typeof displayableResult === 'string' && (
<Box flexDirection="column"> <Box flexDirection="column">
@ -89,7 +78,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/> />
</Box> </Box>
)} )}
{typeof displayableResult === 'object' && ( {typeof displayableResult !== 'string' && (
<DiffRenderer <DiffRenderer
diffContent={displayableResult.fileDiff} diffContent={displayableResult.fileDiff}
filename={displayableResult.fileName} filename={displayableResult.fileName}
@ -109,3 +98,76 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
</Box> </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(() => { useEffect(() => {
// effect for executing scheduled tool calls // 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; const signal = abortController.signal;
toolCalls.forEach((c) => { toolCalls
const callId = c.request.callId; .filter((t) => t.status === 'scheduled')
setToolCalls(setStatus(c.request.callId, 'executing')); .forEach((t) => {
c.tool const callId = t.request.callId;
.execute(c.request.args, signal) setToolCalls(setStatus(t.request.callId, 'executing'));
.then((result) => { t.tool
if (signal.aborted) { .execute(t.request.args, signal)
setToolCalls( .then((result) => {
setStatus(callId, 'cancelled', 'Cancelled during execution'), if (signal.aborted) {
); setToolCalls(
return; setStatus(callId, 'cancelled', 'Cancelled during execution'),
} );
const functionResponse: Part = { return;
functionResponse: { }
name: c.request.name, const functionResponse: Part = {
id: callId, functionResponse: {
response: { output: result.llmContent }, name: t.request.name,
}, id: callId,
}; response: { output: result.llmContent },
const response: ToolCallResponseInfo = { },
callId, };
responsePart: functionResponse, const response: ToolCallResponseInfo = {
resultDisplay: result.returnDisplay,
error: undefined,
};
setToolCalls(setStatus(callId, 'success', response));
})
.catch((e) =>
setToolCalls(
setStatus(
callId, callId,
'error', responsePart: functionResponse,
toolErrorResponse( resultDisplay: result.returnDisplay,
c.request, error: undefined,
e instanceof Error ? e : new Error(String(e)), };
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]); }, [toolCalls, toolRegistry, abortController.signal]);