feat: add emphasis to tool confirmations (#502)
This commit is contained in:
parent
1d0856dcc8
commit
01971741e0
|
@ -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": {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,10 +52,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
marginBottom={1}
|
marginBottom={1}
|
||||||
>
|
>
|
||||||
{toolCalls.map((tool) => (
|
{toolCalls.map((tool) => {
|
||||||
|
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||||
|
return (
|
||||||
<Box key={tool.callId} flexDirection="column">
|
<Box key={tool.callId} flexDirection="column">
|
||||||
|
<Box flexDirection="row" alignItems="center">
|
||||||
<ToolMessage
|
<ToolMessage
|
||||||
key={tool.callId}
|
|
||||||
callId={tool.callId}
|
callId={tool.callId}
|
||||||
name={tool.name}
|
name={tool.name}
|
||||||
description={tool.description}
|
description={tool.description}
|
||||||
|
@ -61,16 +65,25 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||||
status={tool.status}
|
status={tool.status}
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
availableTerminalHeight={availableTerminalHeight - staticHeight}
|
availableTerminalHeight={availableTerminalHeight - staticHeight}
|
||||||
|
emphasis={
|
||||||
|
isConfirming
|
||||||
|
? 'high'
|
||||||
|
: toolAwaitingApproval
|
||||||
|
? 'low'
|
||||||
|
: 'medium'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
{tool.status === ToolCallStatus.Confirming &&
|
{tool.status === ToolCallStatus.Confirming &&
|
||||||
tool.callId === toolAwaitingApproval?.callId &&
|
isConfirming &&
|
||||||
tool.confirmationDetails && (
|
tool.confirmationDetails && (
|
||||||
<ToolConfirmationMessage
|
<ToolConfirmationMessage
|
||||||
confirmationDetails={tool.confirmationDetails}
|
confirmationDetails={tool.confirmationDetails}
|
||||||
></ToolConfirmationMessage>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) {
|
|
||||||
const lines = resultDisplay.split('\n');
|
|
||||||
// Estimate available height for this specific tool message content area
|
// Estimate available height for this specific tool message content area
|
||||||
// This is a rough estimate; ideally, we'd have a more precise measurement.
|
// 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.
|
const displayableResult = React.useMemo(
|
||||||
if (lines.length > contentHeightEstimate && contentHeightEstimate > 0) {
|
() =>
|
||||||
displayableResult = lines.slice(0, contentHeightEstimate).join('\n');
|
resultIsString
|
||||||
hiddenLines = lines.length - contentHeightEstimate;
|
? lines.slice(0, contentHeightEstimate).join('\n')
|
||||||
}
|
: resultDisplay,
|
||||||
}
|
[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>
|
||||||
<Box>
|
{displayableResult && (
|
||||||
<Text
|
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%">
|
||||||
wrap="truncate-end"
|
|
||||||
strikethrough={status === ToolCallStatus.Canceled}
|
|
||||||
>
|
|
||||||
<Text bold>{name}</Text>{' '}
|
|
||||||
<Text color={Colors.SubtleComment}>{description}</Text>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{hasResult && (
|
|
||||||
<Box paddingLeft={statusIndicatorWidth} 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>
|
||||||
|
);
|
||||||
|
|
|
@ -184,13 +184,18 @@ 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'));
|
||||||
|
t.tool
|
||||||
|
.execute(t.request.args, signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
setToolCalls(
|
setToolCalls(
|
||||||
|
@ -200,7 +205,7 @@ export function useToolScheduler(
|
||||||
}
|
}
|
||||||
const functionResponse: Part = {
|
const functionResponse: Part = {
|
||||||
functionResponse: {
|
functionResponse: {
|
||||||
name: c.request.name,
|
name: t.request.name,
|
||||||
id: callId,
|
id: callId,
|
||||||
response: { output: result.llmContent },
|
response: { output: result.llmContent },
|
||||||
},
|
},
|
||||||
|
@ -219,7 +224,7 @@ export function useToolScheduler(
|
||||||
callId,
|
callId,
|
||||||
'error',
|
'error',
|
||||||
toolErrorResponse(
|
toolErrorResponse(
|
||||||
c.request,
|
t.request,
|
||||||
e instanceof Error ? e : new Error(String(e)),
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in New Issue