/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo } from 'react'; import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import type { TextEmphasis } from './ToolShared.js'; import { ToolMessage } from './ToolMessage.js'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { Colors } from '../../colors.js'; import { theme } from '../../semantic-colors.js'; import { DEFAULT_AGENT_ID, formatTodoListForDisplay, } from '@vybestack/llxprt-code-core'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { useTodoContext } from '../../contexts/TodoContext.js'; import type { CliUiRuntime } from '../../cliUiRuntime.js'; import { getBorderStyle } from '../../contexts/UnicodeRenderingContext.js'; interface ToolGroupMessageProps { groupId: number; toolCalls: IndividualToolCallDisplay[]; agentId?: string; availableTerminalHeight?: number; terminalWidth: number; config: CliUiRuntime; isFocused?: boolean; showTodoPanel?: boolean; activeShellPtyId?: number | null; embeddedShellFocused?: boolean; } const extractCountFromText = (text?: string): number | undefined => { if (!text) { return undefined; } // Extract a leading count before "tasks"/"items". The bounded digit // quantifier avoids sonarjs/slow-regex and the pattern is passed to RegExp via // an identifier so it is not a static literal flagged by sonarjs/regular-expr. const countPattern = '(\\d{1,15})\\s+(tasks?|items?)'; const match = text.match(new RegExp(countPattern, 'i')); if (!match) { return undefined; } return Number(match[1]); }; const normalizeToolName = (name: string): string => name.replace(/[^a-z]/gi, '').toLowerCase(); const isTodoReadTool = (name: string): boolean => normalizeToolName(name) === 'todoread'; const isTodoWriteTool = (name: string): boolean => normalizeToolName(name) === 'todowrite'; const formatTaskCountLabel = (count: number): string => { const normalized = Math.max(count, 0); return `${normalized} ${normalized === 1 ? 'task' : 'tasks'}`; }; const formatTodoWriteSummary = (count: number): string => `✦ Todo list updated (${formatTaskCountLabel(count)}).`; const formatTodoReadSummary = (count: number): string => `Todo list read (${formatTaskCountLabel(count)}).`; const deriveTodoCount = ( tool: IndividualToolCallDisplay, fallbackCount: number, ): number => { const fromResult = typeof tool.resultDisplay === 'string' ? extractCountFromText(tool.resultDisplay) : undefined; if (fromResult !== undefined) { return fromResult; } const fromDescription = extractCountFromText(tool.description); if (fromDescription !== undefined) { return fromDescription; } return Math.max(fallbackCount, 0); }; function transformTodoToolCall( tool: IndividualToolCallDisplay, isTodoPanelEnabled: boolean, todosLength: number, textualTodoOutput: string, ): IndividualToolCallDisplay | null { if (isTodoPanelEnabled) { const count = deriveTodoCount(tool, todosLength); if (isTodoReadTool(tool.name)) { return { ...tool, resultDisplay: formatTodoReadSummary(count), renderOutputAsMarkdown: false, }; } if (isTodoWriteTool(tool.name)) { return { ...tool, resultDisplay: formatTodoWriteSummary(count), renderOutputAsMarkdown: false, }; } return tool; } if (isTodoWriteTool(tool.name)) { return { ...tool, resultDisplay: textualTodoOutput, renderOutputAsMarkdown: true, }; } return tool; } function computeAvailableHeightPerTool( availableTerminalHeight: number | undefined, staticHeight: number, filteredToolCalls: IndividualToolCallDisplay[], ): number | undefined { let countToolCallsWithResults = 0; for (const tool of filteredToolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { countToolCallsWithResults++; } } const countOneLineToolCalls = filteredToolCalls.length - countToolCallsWithResults; const hasAvailableTerminalHeight = availableTerminalHeight != null && availableTerminalHeight !== 0 && !Number.isNaN(availableTerminalHeight); if (!hasAvailableTerminalHeight) { return undefined; } return Math.max( Math.floor( (availableTerminalHeight - staticHeight - countOneLineToolCalls) / Math.max(1, countToolCallsWithResults), ), 1, ); } function deriveBorderColors(filteredToolCalls: IndividualToolCallDisplay[]): { borderColor: string; borderDimColor: boolean; isShellCommand: boolean; } { const hasPending = !filteredToolCalls.every( (t) => t.status === ToolCallStatus.Success, ); const isShellCommand = filteredToolCalls.some( (t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME, ); const getBorderColor = () => { if (isShellCommand) { return theme.ui.symbol; } return hasPending ? theme.status.warning : theme.border.default; }; const borderColor = getBorderColor(); const borderDimColor = hasPending && !isShellCommand; return { borderColor, borderDimColor, isShellCommand }; } function renderToolCallItem( tool: IndividualToolCallDisplay, index: number, toolAwaitingApproval: IndividualToolCallDisplay | undefined, availableTerminalHeightPerToolMessage: number | undefined, innerWidth: number, isFocused: boolean, activeShellPtyId: number | null | undefined, embeddedShellFocused: boolean | undefined, config: CliUiRuntime, borderColor: string, borderDimColor: boolean, ): React.ReactNode { const isConfirming = toolAwaitingApproval?.callId === tool.callId; const isFirst = index === 0; let emphasis: TextEmphasis = 'medium'; if (isConfirming) { emphasis = 'high'; } else if (toolAwaitingApproval !== undefined) { emphasis = 'low'; } const showMessageConfirmation = tool.status === ToolCallStatus.Confirming && isConfirming && tool.confirmationDetails !== undefined; return ( {showMessageConfirmation && tool.confirmationDetails && ( )} ); } function useToolGroupState( toolCalls: IndividualToolCallDisplay[], isTodoPanelEnabled: boolean, todosLength: number, textualTodoOutput: string, ): { toolAwaitingApproval: IndividualToolCallDisplay | undefined; filteredToolCalls: IndividualToolCallDisplay[]; } { const toolAwaitingApproval = useMemo( () => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming), [toolCalls], ); const filteredToolCalls = useMemo( () => toolCalls .map((tool) => transformTodoToolCall( tool, isTodoPanelEnabled, todosLength, textualTodoOutput, ), ) .filter((t): t is IndividualToolCallDisplay => t !== null), [toolCalls, isTodoPanelEnabled, textualTodoOutput, todosLength], ); return { toolAwaitingApproval, filteredToolCalls }; } // Main component renders the border and maps the tools using ToolMessage export const ToolGroupMessage: React.FC = ({ toolCalls, agentId, availableTerminalHeight, terminalWidth, config, isFocused = true, showTodoPanel = true, activeShellPtyId, embeddedShellFocused, }) => { const { todos } = useTodoContext(); const isTodoPanelEnabled = showTodoPanel; const textualTodoOutput = useMemo( () => formatTodoListForDisplay(todos), [todos], ); const { toolAwaitingApproval, filteredToolCalls } = useToolGroupState( toolCalls, isTodoPanelEnabled, todos.length, textualTodoOutput, ); if (filteredToolCalls.length === 0) { return null; } const { borderColor, borderDimColor } = deriveBorderColors(filteredToolCalls); const staticHeight = 2 + 1; const innerWidth = terminalWidth; const availableTerminalHeightPerToolMessage = computeAvailableHeightPerTool( availableTerminalHeight, staticHeight, filteredToolCalls, ); return ( {agentId && agentId !== DEFAULT_AGENT_ID && ( {`Agent: ${agentId}`} )} {filteredToolCalls.map((tool, index) => renderToolCallItem( tool, index, toolAwaitingApproval, availableTerminalHeightPerToolMessage, innerWidth, isFocused, activeShellPtyId, embeddedShellFocused, config, borderColor, borderDimColor, ), )} ); };