diff --git a/package-lock.json b/package-lock.json index f62ba518..878a4f5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index a0b07634..c60f07d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 4b2c7dfe..8bcde3bb 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -29,6 +29,8 @@ export const ToolGroupMessage: React.FC = ({ 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 = ({ borderColor={borderColor} marginBottom={1} > - {toolCalls.map((tool) => ( - - - {tool.status === ToolCallStatus.Confirming && - tool.callId === toolAwaitingApproval?.callId && - tool.confirmationDetails && ( - { + const isConfirming = toolAwaitingApproval?.callId === tool.callId; + return ( + + + - )} - - ))} + availableTerminalHeight={availableTerminalHeight - staticHeight} + emphasis={ + isConfirming + ? 'high' + : toolAwaitingApproval + ? 'low' + : 'medium' + } + /> + + {tool.status === ToolCallStatus.Confirming && + isConfirming && + tool.confirmationDetails && ( + + )} + + ); + })} ); }; diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 32b23b9e..32b3b7e8 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -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 = ({ @@ -22,63 +29,45 @@ export const ToolMessage: React.FC = ({ 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 ( {/* Status Indicator */} - - {(status === ToolCallStatus.Pending || - status === ToolCallStatus.Executing) && } - {status === ToolCallStatus.Success && ( - - )} - {status === ToolCallStatus.Confirming && ( - ? - )} - {status === ToolCallStatus.Canceled && ( - - - - - )} - {status === ToolCallStatus.Error && ( - - x - - )} - - - - {name}{' '} - {description} - - + + + {emphasis === 'high' && } - {hasResult && ( - + {displayableResult && ( + {typeof displayableResult === 'string' && ( @@ -89,7 +78,7 @@ export const ToolMessage: React.FC = ({ /> )} - {typeof displayableResult === 'object' && ( + {typeof displayableResult !== 'string' && ( = ({ ); }; + +type ToolStatusIndicator = { + status: ToolCallStatus; +}; +const ToolStatusIndicator: React.FC = ({ status }) => ( + + {status === ToolCallStatus.Pending && ( + o + )} + {status === ToolCallStatus.Executing && } + {status === ToolCallStatus.Success && ( + + )} + {status === ToolCallStatus.Confirming && ( + ? + )} + {status === ToolCallStatus.Canceled && ( + + - + + )} + {status === ToolCallStatus.Error && ( + + x + + )} + +); + +type ToolInfo = { + name: string; + description: string; + status: ToolCallStatus; + emphasis: TextEmphasis; +}; +const ToolInfo: React.FC = ({ + name, + description, + status, + emphasis, +}) => { + const nameColor = React.useMemo(() => { + 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 ( + + + + {name} + {' '} + {description} + + + ); +}; + +const TrailingIndicator: React.FC = () => ( + +); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.ts b/packages/cli/src/ui/hooks/useToolScheduler.ts index 8bcc0ae9..a5770d36 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.ts @@ -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]);