From 29ebaf7886d282db260d693ec6ed0e5648bb42d4 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 15:34:34 +0100 Subject: [PATCH] Show Assistant active command status --- .../v6/internal/subsystems/ai-runtime.md | 5 + .../components/AI/Chat/ToolExecutionBlock.tsx | 420 +----------------- .../Chat/__tests__/activeTurnStatus.test.ts | 54 +++ .../components/AI/Chat/activeTurnStatus.ts | 24 +- .../components/AI/Chat/toolPresentation.ts | 412 +++++++++++++++++ 5 files changed, 503 insertions(+), 412 deletions(-) create mode 100644 frontend-modern/src/components/AI/Chat/toolPresentation.ts diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 745beb029..3440b79a5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -360,6 +360,11 @@ runtime cost control, and shared AI transport surfaces. `session.next.tool.input.*` mutations in `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`: typed tool state owns the action row, while raw invocation syntax is inspection detail. + The live active-turn footer is part of the same contract: pending tools must + reuse the tool-row action/command summary instead of falling back to generic + tool names such as `read` or `query`. This keeps the always-visible progress + surface aligned with OpenCode's visible `ToolPart` state rather than hiding + the concrete command until the transcript row catches up. Streaming thinking rows follow the same source-anchored reasoning-display contract: OpenCode commit `9ed17da55ab1f7360cc0e01075f763e27fa899e9` renders reasoning through diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index aaf5e57c4..9cce8cd96 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -5,402 +5,19 @@ import LoaderCircleIcon from 'lucide-solid/icons/loader-circle'; import XCircleIcon from 'lucide-solid/icons/x-circle'; import type { ToolExecution, PendingTool } from './types'; import { getToolCallResultTextClass } from '@/utils/patrolRunPresentation'; -import { formatIdentifierLabel } from '@/utils/textPresentation'; +import { + getToolLabel, + isPlaceholderToolInputSummary, + parseToolInputSummary, + pendingToolActionLabel, + pendingToolActionState, + toolValueText, +} from './toolPresentation'; interface ToolExecutionBlockProps { tool: ToolExecution; } -const getToolLabel = (name: string) => { - if (name === 'run_command' || name === 'pulse_run_command') return 'cmd'; - if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch'; - if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') - return 'infra'; - if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts'; - if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics'; - if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines'; - if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; - if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; - if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; - if (name === 'pulse_get_storage_config') return 'storage cfg'; - if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; - if (name.includes('finding')) return 'finding'; - return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 }); -}; - -const normalizedToolName = (name?: string) => (name || '').trim().replace(/^pulse_/, ''); - -const pendingToolActionLabel = (name?: string) => { - const tool = normalizedToolName(name); - if (tool === 'run_command' || tool === 'control') return 'Writing command...'; - if (tool === 'read') return 'Preparing read...'; - if (tool === 'query') return 'Preparing query...'; - if (tool === 'fetch_url') return 'Fetching URL...'; - if (tool === 'get_infrastructure_state') return 'Reading infrastructure...'; - if (tool === 'get_active_alerts' || tool === 'alerts') return 'Reading alerts...'; - if (tool === 'get_metrics_history') return 'Reading metrics...'; - if (tool === 'get_baselines') return 'Reading baselines...'; - if (tool === 'get_patterns') return 'Reading patterns...'; - if (tool === 'get_disk_health') return 'Checking disks...'; - if (tool === 'get_storage' || tool === 'get_storage_config') return 'Reading storage...'; - if (tool === 'get_resource_details') return 'Reading resource...'; - if ((name || '').includes('finding')) return 'Preparing finding...'; - return 'Preparing tool...'; -}; - -const pendingToolActionState = (name?: string) => { - const tool = normalizedToolName(name); - if (tool === 'run_command' || tool === 'control') return 'writing'; - if (tool === 'query') return 'preparing'; - if (tool === 'fetch_url') return 'fetching'; - if (tool === 'get_disk_health') return 'checking'; - if ( - tool === 'read' || - tool === 'get_infrastructure_state' || - tool === 'get_active_alerts' || - tool === 'alerts' || - tool === 'get_metrics_history' || - tool === 'get_baselines' || - tool === 'get_patterns' || - tool === 'get_storage' || - tool === 'get_storage_config' || - tool === 'get_resource_details' - ) { - return 'reading'; - } - return 'preparing'; -}; - -const isPlaceholderToolInputSummary = (summary: string) => { - const normalized = summary.trim().toLowerCase(); - return !normalized || normalized === 'request' || normalized === 'receiving input'; -}; - -const escapePartialJSONKey = (key: string) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -const extractPartialJSONStringField = (rawInput: string, keys: string[]) => { - const raw = rawInput.trim(); - if (!raw) return ''; - - for (const key of keys) { - const escapedKey = escapePartialJSONKey(key); - const match = raw.match(new RegExp(`"${escapedKey}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`)); - if (match?.[1]) { - return match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim(); - } - } - return ''; -}; - -const stringField = (record: Record, keys: string[]) => { - for (const key of keys) { - const value = record[key]; - if (typeof value === 'string' && value.trim()) { - return value.trim(); - } - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value); - } - } - return ''; -}; - -const booleanField = (record: Record, key: string) => record[key] === true; - -const inlineValue = (value: string, maxLength = 24) => - value - .replace(/["\r\n]+/g, ' ') - .trim() - .substring(0, maxLength); - -const targetSuffix = (record: Record) => { - const target = stringField(record, ['target_host', 'targetHost', 'resource_id', 'resourceId']); - if (!target) return ''; - return ` on ${formatIdentifierLabel(target, { maxLength: 18 })}`; -}; - -const parseFunctionStyleToolInput = (input: string) => { - const match = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*)\)\s*$/.exec(input.trim()); - if (!match) return null; - - const args: Record = {}; - const body = match[2] || ''; - let index = 0; - - const skipWhitespace = () => { - while (index < body.length && /\s/.test(body[index])) index += 1; - }; - - const parseQuotedValue = (quote: string) => { - index += 1; - let value = ''; - while (index < body.length) { - const char = body[index]; - if (char === '\\') { - if (index + 1 < body.length) { - value += body[index + 1]; - index += 2; - continue; - } - return null; - } - if (char === quote) { - index += 1; - return value; - } - value += char; - index += 1; - } - return null; - }; - - while (index < body.length) { - skipWhitespace(); - if (index >= body.length) break; - - if (body[index] === ',') { - index += 1; - continue; - } - - const keyMatch = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(body.slice(index)); - if (!keyMatch) return null; - const key = keyMatch[0]; - index += key.length; - - skipWhitespace(); - if (body[index] !== '=') return null; - index += 1; - skipWhitespace(); - - const quote = body[index]; - let value: unknown; - if (quote === '"' || quote === "'") { - value = parseQuotedValue(quote); - if (value === null) return null; - } else { - const start = index; - while (index < body.length && body[index] !== ',') index += 1; - const rawValue = body.slice(start, index).trim(); - if (!rawValue) return null; - if (rawValue === 'true') value = true; - else if (rawValue === 'false') value = false; - else if (rawValue === 'null') value = null; - else if (/^-?\d+(?:\.\d+)?$/.test(rawValue)) value = Number(rawValue); - else value = rawValue; - } - - args[key] = value; - skipWhitespace(); - if (index < body.length && body[index] !== ',') return null; - } - - return { name: match[1], args }; -}; - -const formatPartialRawInputSummary = (rawInput: string | undefined, toolName?: string) => { - if (!rawInput?.trim()) return ''; - - const action = extractPartialJSONStringField(rawInput, ['action', 'type']).toLowerCase(); - const command = extractPartialJSONStringField(rawInput, ['command', 'cmd']); - const path = extractPartialJSONStringField(rawInput, ['path', 'file', 'file_path', 'filePath']); - const pattern = extractPartialJSONStringField(rawInput, ['pattern', 'grep', 'grep_pattern', 'grepPattern']); - const query = extractPartialJSONStringField(rawInput, ['query', 'search', 'name', 'resource_name']); - const target = extractPartialJSONStringField(rawInput, [ - 'target_host', - 'targetHost', - 'resource_id', - 'resourceId', - ]); - const record: Record = { - ...(action ? { action } : {}), - ...(command ? { command } : {}), - ...(path ? { path } : {}), - ...(pattern ? { pattern } : {}), - ...(query ? { query } : {}), - ...(target ? { target_host: target } : {}), - }; - const tool = normalizedToolName(toolName); - - if ((tool === 'read' || tool === 'run_command' || tool === 'control') && command) { - return `$ ${inlineValue(command, 64)}${targetSuffix(record)}`; - } - if (tool === 'read') { - if (action === 'file' && path) return `read ${inlineValue(path, 36)}${targetSuffix(record)}`; - if (action === 'tail' && path) return `tail ${inlineValue(path, 36)}${targetSuffix(record)}`; - if (action === 'find' && pattern) return `find "${inlineValue(pattern, 28)}"${targetSuffix(record)}`; - if (action === 'logs') return `read logs${targetSuffix(record)}`; - } - if (tool === 'query') { - if (action === 'search' && query) return `search "${inlineValue(query, 28)}"`; - if (action === 'list') return 'list resources'; - if (action) return formatIdentifierLabel(action, { maxLength: 28 }); - } - if (action) return formatIdentifierLabel(action, { maxLength: 28 }); - return 'receiving input'; -}; - -const formatCommandSummary = (record: Record) => { - const command = stringField(record, ['command', 'cmd']); - if (!command) return ''; - return `$ ${inlineValue(command, 64)}${targetSuffix(record)}`; -}; - -const formatPulseReadInputSummary = (record: Record) => { - const action = stringField(record, ['action', 'type']).toLowerCase(); - const path = inlineValue(stringField(record, ['path', 'file', 'file_path', 'filePath']), 36); - const pattern = inlineValue( - stringField(record, ['pattern', 'grep', 'grep_pattern', 'grepPattern']), - 28, - ); - const container = inlineValue(stringField(record, ['container', 'service', 'unit']), 24); - const source = stringField(record, ['source']).toLowerCase(); - - if (action === 'exec') { - return formatCommandSummary(record) || `run read-only command${targetSuffix(record)}`; - } - if (action === 'file') { - return path ? `read ${path}${targetSuffix(record)}` : `read file${targetSuffix(record)}`; - } - if (action === 'tail') { - return path ? `tail ${path}${targetSuffix(record)}` : `tail file${targetSuffix(record)}`; - } - if (action === 'find') { - if (pattern && path) return `find "${pattern}" in ${path}${targetSuffix(record)}`; - return pattern - ? `find "${pattern}"${targetSuffix(record)}` - : `find files${targetSuffix(record)}`; - } - if (action === 'logs') { - if (container) return `logs ${container}${targetSuffix(record)}`; - return source - ? `${formatIdentifierLabel(source, { maxLength: 18 })} logs${targetSuffix(record)}` - : `read logs${targetSuffix(record)}`; - } - return ( - formatCommandSummary(record) || (action ? formatIdentifierLabel(action, { maxLength: 28 }) : '') - ); -}; - -const formatQueryInputSummary = (record: Record) => { - const action = stringField(record, ['action', 'type']).toLowerCase(); - const resourceType = stringField(record, ['resource_type', 'resourceType', 'kind', 'include']); - const listType = stringField(record, ['type', 'resource_type', 'resourceType', 'kind']); - const query = inlineValue(stringField(record, ['query', 'search', 'name', 'resource_name'])); - const resourceId = inlineValue(stringField(record, ['resource_id', 'resourceId', 'id', 'vmid'])); - const node = inlineValue(stringField(record, ['node', 'host'])); - - switch (action) { - case 'search': - return query ? `search "${query}"` : 'search resources'; - case 'list': - return listType - ? `list ${formatIdentifierLabel(listType, { maxLength: 22 })}` - : 'list resources'; - case 'get': - return resourceId ? `get ${resourceId}` : 'get resource'; - case 'config': - if (resourceId && node) return `config ${resourceId} on ${node}`; - return resourceId ? `config ${resourceId}` : 'resource config'; - case 'topology': - return booleanField(record, 'summary_only') ? 'topology summary' : 'topology'; - case 'health': - return 'health summary'; - default: - if (action) return formatIdentifierLabel(action, { maxLength: 28 }); - return resourceType - ? `query ${formatIdentifierLabel(resourceType, { maxLength: 22 })}` - : 'query resources'; - } -}; - -const formatAlertsInputSummary = (record: Record) => { - const action = stringField(record, ['action']).toLowerCase(); - const severity = stringField(record, ['severity', 'level']); - const resource = stringField(record, ['resource', 'resource_id', 'resourceId', 'name']); - - if (action === 'list') { - return severity - ? `list ${formatIdentifierLabel(severity, { maxLength: 18 })} alerts` - : 'list active alerts'; - } - if (action === 'get') { - return resource ? `get alert for ${inlineValue(resource, 18)}` : 'get alert'; - } - return action ? formatIdentifierLabel(action, { maxLength: 28 }) : 'read alerts'; -}; - -const formatStructuredInputSummary = ( - record: Record, - toolName?: string, - rawInput?: string, -) => { - if (Object.keys(record).length === 0) { - return formatPartialRawInputSummary(rawInput, toolName) || 'request'; - } - - const tool = normalizedToolName(toolName); - if (tool === 'read') { - return formatPulseReadInputSummary(record) || 'read resource'; - } - if (tool === 'run_command' || tool === 'control') { - return formatCommandSummary(record) || 'run command'; - } - if (tool === 'query') { - return formatQueryInputSummary(record); - } - if (tool === 'alerts') { - return formatAlertsInputSummary(record); - } - if (typeof record.action === 'string' && record.action.trim()) { - return formatIdentifierLabel(record.action, { maxLength: 28 }); - } - if (typeof record.command === 'string' && record.command.trim()) { - return formatIdentifierLabel(record.command, { maxLength: 28 }); - } - return 'request'; -}; - -const parseStructuredInputSummary = (input: string, toolName?: string, rawInput?: string) => { - const trimmed = input.trim(); - if (!trimmed) return null; - - try { - const parsed = JSON.parse(trimmed) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return formatStructuredInputSummary(parsed as Record, toolName, rawInput); - } - } catch { - // Keep trying provider-style function-call input below. - } - - const functionCall = parseFunctionStyleToolInput(trimmed); - if (functionCall) { - return formatStructuredInputSummary(functionCall.args, functionCall.name, rawInput); - } - - return null; -}; - -const parseToolInputSummary = (input: string, toolName?: string, rawInput?: string) => { - const trimmed = input.trim(); - if (!trimmed) return ''; - - const directSummary = parseStructuredInputSummary(trimmed, toolName, rawInput); - if (directSummary) { - return directSummary; - } - - if (rawInput && rawInput.trim() && rawInput.trim() !== trimmed) { - const rawSummary = parseStructuredInputSummary(rawInput, toolName); - if (rawSummary && !isPlaceholderToolInputSummary(rawSummary)) { - return rawSummary; - } - } - - return formatIdentifierLabel(trimmed, { maxLength: 28 }); -}; - const hasReadableToolOutput = (output: string) => { const trimmed = output.trim(); return trimmed.length > 0 && !trimmed.toLowerCase().includes('not available'); @@ -438,9 +55,7 @@ const formatOutputPreview = (output: string) => { const maxLines = 4; const maxLineLength = 120; const lines = normalized.split('\n'); - const previewLines = lines - .slice(0, maxLines) - .map((line) => trimPreviewLine(line, maxLineLength)); + const previewLines = lines.slice(0, maxLines).map((line) => trimPreviewLine(line, maxLineLength)); const fullPreview = previewLines.join('\n').trim(); if (!fullPreview) return ''; @@ -451,16 +66,6 @@ const formatOutputPreview = (output: string) => { return linesTruncated || charsTruncated ? `${fullPreview}\n...` : fullPreview; }; -const toolValueText = (value: unknown) => { - if (typeof value === 'string') return value; - if (value === null || value === undefined) return ''; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -}; - /** * ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style. */ @@ -567,14 +172,17 @@ export const PendingToolBlock: Component = (props) => { ); const inputSummary = createMemo(() => { const summary = parsedInputSummary(); - return isPlaceholderToolInputSummary(summary) ? pendingToolActionLabel(props.tool.name) : summary; + return isPlaceholderToolInputSummary(summary) + ? pendingToolActionLabel(props.tool.name) + : summary; }); const status = createMemo(() => props.tool.status || 'pending'); const [now, setNow] = createSignal(Date.now()); const statusLabel = createMemo(() => { if (status() === 'waiting') return 'waiting'; if (status() === 'running') return 'running'; - if (isPlaceholderToolInputSummary(parsedInputSummary())) return pendingToolActionState(props.tool.name); + if (isPlaceholderToolInputSummary(parsedInputSummary())) + return pendingToolActionState(props.tool.name); return 'pending'; }); const progressText = createMemo(() => (props.tool.progress || '').trim()); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts index 9ea98b5b4..887aaa583 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -40,6 +40,60 @@ describe('getAssistantActiveTurnStatus', () => { }); }); + it('surfaces the active pending command instead of the generic tool name', () => { + const startedAt = Date.now() - 5_000; + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + pendingTools: [ + { + id: 'tool-1', + name: 'pulse_read', + input: + '{"action":"exec","target_host":"current_resource","command":"ls /dev | wc -l"}', + status: 'running', + startedAt, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'tool', + text: 'Running $ ls /dev | wc -l on current resource', + startedAt, + }); + }); + + it('surfaces provider-style pending command input in the active turn status', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + streamEvents: [ + { + type: 'pending_tool', + pendingTool: { + id: 'tool-1', + name: 'pulse_read', + input: 'pulse_read(target_host="current_resource", command="ls /dev | wc -l")', + status: 'running', + }, + toolId: 'tool-1', + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'tool', + text: 'Running $ ls /dev | wc -l on current resource', + }); + }); + it('carries workflow start time so the live footer can show elapsed wait', () => { const startedAt = Date.now() - 8_000; expect( diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 01830f730..b3adc009a 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -2,6 +2,11 @@ import type { ChatMessage, PendingTool, StreamDisplayEvent, WorkflowStatus } fro import { formatIdentifierLabel } from '@/utils/textPresentation'; import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation'; import { extractReasoningSummaryTitle } from './reasoningSummary'; +import { + isPlaceholderToolInputSummary, + parseToolInputSummary, + toolValueText, +} from './toolPresentation'; export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating'; @@ -34,8 +39,8 @@ export const formatAssistantWorkflowStatus = (status?: WorkflowStatus): string = const toolLabel = tool ? formatToolName(tool) : ''; const toolSuffix = toolLabel && !message.includes(tool || '') && !messageContainsToolLabel(message, toolLabel) - ? ` · ${toolLabel}` - : ''; + ? ` · ${toolLabel}` + : ''; return `${message}${toolSuffix}`; }; @@ -46,15 +51,20 @@ const formatPendingToolStatus = (tool?: PendingTool): string => { const progress = tool.progress?.trim(); if (progress) return progress; + const inputSummary = parseToolInputSummary(toolValueText(tool.input), tool.name, tool.rawInput); + const toolActivity = isPlaceholderToolInputSummary(inputSummary) ? '' : inputSummary; + if (toolActivity) { + if (tool.status === 'waiting') return `Waiting on ${toolActivity}`; + return `Running ${toolActivity}`; + } + const toolLabel = formatToolName(tool.name); if (tool.status === 'waiting') return `Waiting on ${toolLabel}`; return `Running ${toolLabel}`; }; -const activePendingToolFromEvents = ( - events?: StreamDisplayEvent[], -): PendingTool | undefined => { +const activePendingToolFromEvents = (events?: StreamDisplayEvent[]): PendingTool | undefined => { const completedToolKeys = new Set(); for (let index = (events?.length || 0) - 1; index >= 0; index -= 1) { @@ -296,7 +306,9 @@ export const getAssistantActiveTurnStatus = ( } const statusCandidates: AssistantActiveTurnStatusCandidate[] = []; - const statePendingToolCandidate = activePendingToolCandidateFromState(assistantMessage.pendingTools); + const statePendingToolCandidate = activePendingToolCandidateFromState( + assistantMessage.pendingTools, + ); if (statePendingToolCandidate) { statusCandidates.push(statePendingToolCandidate); } diff --git a/frontend-modern/src/components/AI/Chat/toolPresentation.ts b/frontend-modern/src/components/AI/Chat/toolPresentation.ts new file mode 100644 index 000000000..3417b1cc9 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/toolPresentation.ts @@ -0,0 +1,412 @@ +import { formatIdentifierLabel } from '@/utils/textPresentation'; + +export const getToolLabel = (name: string) => { + if (name === 'run_command' || name === 'pulse_run_command') return 'cmd'; + if (name === 'fetch_url' || name === 'pulse_fetch_url') return 'fetch'; + if (name === 'get_infrastructure_state' || name === 'pulse_get_infrastructure_state') + return 'infra'; + if (name === 'get_active_alerts' || name === 'pulse_get_active_alerts') return 'alerts'; + if (name === 'get_metrics_history' || name === 'pulse_get_metrics_history') return 'metrics'; + if (name === 'get_baselines' || name === 'pulse_get_baselines') return 'baselines'; + if (name === 'get_patterns' || name === 'pulse_get_patterns') return 'patterns'; + if (name === 'get_disk_health' || name === 'pulse_get_disk_health') return 'disks'; + if (name === 'get_storage' || name === 'pulse_get_storage') return 'storage'; + if (name === 'pulse_get_storage_config') return 'storage cfg'; + if (name === 'get_resource_details' || name === 'pulse_get_resource_details') return 'resource'; + if (name.includes('finding')) return 'finding'; + return formatIdentifierLabel(name, { stripPrefix: 'pulse_', maxLength: 12 }); +}; + +const normalizedToolName = (name?: string) => (name || '').trim().replace(/^pulse_/, ''); + +export const pendingToolActionLabel = (name?: string) => { + const tool = normalizedToolName(name); + if (tool === 'run_command' || tool === 'control') return 'Writing command...'; + if (tool === 'read') return 'Preparing read...'; + if (tool === 'query') return 'Preparing query...'; + if (tool === 'fetch_url') return 'Fetching URL...'; + if (tool === 'get_infrastructure_state') return 'Reading infrastructure...'; + if (tool === 'get_active_alerts' || tool === 'alerts') return 'Reading alerts...'; + if (tool === 'get_metrics_history') return 'Reading metrics...'; + if (tool === 'get_baselines') return 'Reading baselines...'; + if (tool === 'get_patterns') return 'Reading patterns...'; + if (tool === 'get_disk_health') return 'Checking disks...'; + if (tool === 'get_storage' || tool === 'get_storage_config') return 'Reading storage...'; + if (tool === 'get_resource_details') return 'Reading resource...'; + if ((name || '').includes('finding')) return 'Preparing finding...'; + return 'Preparing tool...'; +}; + +export const pendingToolActionState = (name?: string) => { + const tool = normalizedToolName(name); + if (tool === 'run_command' || tool === 'control') return 'writing'; + if (tool === 'query') return 'preparing'; + if (tool === 'fetch_url') return 'fetching'; + if (tool === 'get_disk_health') return 'checking'; + if ( + tool === 'read' || + tool === 'get_infrastructure_state' || + tool === 'get_active_alerts' || + tool === 'alerts' || + tool === 'get_metrics_history' || + tool === 'get_baselines' || + tool === 'get_patterns' || + tool === 'get_storage' || + tool === 'get_storage_config' || + tool === 'get_resource_details' + ) { + return 'reading'; + } + return 'preparing'; +}; + +export const isPlaceholderToolInputSummary = (summary: string) => { + const normalized = summary.trim().toLowerCase(); + return !normalized || normalized === 'request' || normalized === 'receiving input'; +}; + +const escapePartialJSONKey = (key: string) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const extractPartialJSONStringField = (rawInput: string, keys: string[]) => { + const raw = rawInput.trim(); + if (!raw) return ''; + + for (const key of keys) { + const escapedKey = escapePartialJSONKey(key); + const match = raw.match(new RegExp(`"${escapedKey}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`)); + if (match?.[1]) { + return match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\').trim(); + } + } + return ''; +}; + +const stringField = (record: Record, keys: string[]) => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + } + return ''; +}; + +const booleanField = (record: Record, key: string) => record[key] === true; + +const inlineValue = (value: string, maxLength = 24) => + value + .replace(/["\r\n]+/g, ' ') + .trim() + .substring(0, maxLength); + +const targetSuffix = (record: Record) => { + const target = stringField(record, ['target_host', 'targetHost', 'resource_id', 'resourceId']); + if (!target) return ''; + return ` on ${formatIdentifierLabel(target, { maxLength: 18 })}`; +}; + +const parseFunctionStyleToolInput = (input: string) => { + const match = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*)\)\s*$/.exec(input.trim()); + if (!match) return null; + + const args: Record = {}; + const body = match[2] || ''; + let index = 0; + + const skipWhitespace = () => { + while (index < body.length && /\s/.test(body[index])) index += 1; + }; + + const parseQuotedValue = (quote: string) => { + index += 1; + let value = ''; + while (index < body.length) { + const char = body[index]; + if (char === '\\') { + if (index + 1 < body.length) { + value += body[index + 1]; + index += 2; + continue; + } + return null; + } + if (char === quote) { + index += 1; + return value; + } + value += char; + index += 1; + } + return null; + }; + + while (index < body.length) { + skipWhitespace(); + if (index >= body.length) break; + + if (body[index] === ',') { + index += 1; + continue; + } + + const keyMatch = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(body.slice(index)); + if (!keyMatch) return null; + const key = keyMatch[0]; + index += key.length; + + skipWhitespace(); + if (body[index] !== '=') return null; + index += 1; + skipWhitespace(); + + const quote = body[index]; + let value: unknown; + if (quote === '"' || quote === "'") { + value = parseQuotedValue(quote); + if (value === null) return null; + } else { + const start = index; + while (index < body.length && body[index] !== ',') index += 1; + const rawValue = body.slice(start, index).trim(); + if (!rawValue) return null; + if (rawValue === 'true') value = true; + else if (rawValue === 'false') value = false; + else if (rawValue === 'null') value = null; + else if (/^-?\d+(?:\.\d+)?$/.test(rawValue)) value = Number(rawValue); + else value = rawValue; + } + + args[key] = value; + skipWhitespace(); + if (index < body.length && body[index] !== ',') return null; + } + + return { name: match[1], args }; +}; + +const formatPartialRawInputSummary = (rawInput: string | undefined, toolName?: string) => { + if (!rawInput?.trim()) return ''; + + const action = extractPartialJSONStringField(rawInput, ['action', 'type']).toLowerCase(); + const command = extractPartialJSONStringField(rawInput, ['command', 'cmd']); + const path = extractPartialJSONStringField(rawInput, ['path', 'file', 'file_path', 'filePath']); + const pattern = extractPartialJSONStringField(rawInput, [ + 'pattern', + 'grep', + 'grep_pattern', + 'grepPattern', + ]); + const query = extractPartialJSONStringField(rawInput, [ + 'query', + 'search', + 'name', + 'resource_name', + ]); + const target = extractPartialJSONStringField(rawInput, [ + 'target_host', + 'targetHost', + 'resource_id', + 'resourceId', + ]); + const record: Record = { + ...(action ? { action } : {}), + ...(command ? { command } : {}), + ...(path ? { path } : {}), + ...(pattern ? { pattern } : {}), + ...(query ? { query } : {}), + ...(target ? { target_host: target } : {}), + }; + const tool = normalizedToolName(toolName); + + if ((tool === 'read' || tool === 'run_command' || tool === 'control') && command) { + return `$ ${inlineValue(command, 64)}${targetSuffix(record)}`; + } + if (tool === 'read') { + if (action === 'file' && path) return `read ${inlineValue(path, 36)}${targetSuffix(record)}`; + if (action === 'tail' && path) return `tail ${inlineValue(path, 36)}${targetSuffix(record)}`; + if (action === 'find' && pattern) + return `find "${inlineValue(pattern, 28)}"${targetSuffix(record)}`; + if (action === 'logs') return `read logs${targetSuffix(record)}`; + } + if (tool === 'query') { + if (action === 'search' && query) return `search "${inlineValue(query, 28)}"`; + if (action === 'list') return 'list resources'; + if (action) return formatIdentifierLabel(action, { maxLength: 28 }); + } + if (action) return formatIdentifierLabel(action, { maxLength: 28 }); + return 'receiving input'; +}; + +const formatCommandSummary = (record: Record) => { + const command = stringField(record, ['command', 'cmd']); + if (!command) return ''; + return `$ ${inlineValue(command, 64)}${targetSuffix(record)}`; +}; + +const formatPulseReadInputSummary = (record: Record) => { + const action = stringField(record, ['action', 'type']).toLowerCase(); + const path = inlineValue(stringField(record, ['path', 'file', 'file_path', 'filePath']), 36); + const pattern = inlineValue( + stringField(record, ['pattern', 'grep', 'grep_pattern', 'grepPattern']), + 28, + ); + const container = inlineValue(stringField(record, ['container', 'service', 'unit']), 24); + const source = stringField(record, ['source']).toLowerCase(); + + if (action === 'exec') { + return formatCommandSummary(record) || `run read-only command${targetSuffix(record)}`; + } + if (action === 'file') { + return path ? `read ${path}${targetSuffix(record)}` : `read file${targetSuffix(record)}`; + } + if (action === 'tail') { + return path ? `tail ${path}${targetSuffix(record)}` : `tail file${targetSuffix(record)}`; + } + if (action === 'find') { + if (pattern && path) return `find "${pattern}" in ${path}${targetSuffix(record)}`; + return pattern + ? `find "${pattern}"${targetSuffix(record)}` + : `find files${targetSuffix(record)}`; + } + if (action === 'logs') { + if (container) return `logs ${container}${targetSuffix(record)}`; + return source + ? `${formatIdentifierLabel(source, { maxLength: 18 })} logs${targetSuffix(record)}` + : `read logs${targetSuffix(record)}`; + } + return ( + formatCommandSummary(record) || (action ? formatIdentifierLabel(action, { maxLength: 28 }) : '') + ); +}; + +const formatQueryInputSummary = (record: Record) => { + const action = stringField(record, ['action', 'type']).toLowerCase(); + const resourceType = stringField(record, ['resource_type', 'resourceType', 'kind', 'include']); + const listType = stringField(record, ['type', 'resource_type', 'resourceType', 'kind']); + const query = inlineValue(stringField(record, ['query', 'search', 'name', 'resource_name'])); + const resourceId = inlineValue(stringField(record, ['resource_id', 'resourceId', 'id', 'vmid'])); + const node = inlineValue(stringField(record, ['node', 'host'])); + + switch (action) { + case 'search': + return query ? `search "${query}"` : 'search resources'; + case 'list': + return listType + ? `list ${formatIdentifierLabel(listType, { maxLength: 22 })}` + : 'list resources'; + case 'get': + return resourceId ? `get ${resourceId}` : 'get resource'; + case 'config': + if (resourceId && node) return `config ${resourceId} on ${node}`; + return resourceId ? `config ${resourceId}` : 'resource config'; + case 'topology': + return booleanField(record, 'summary_only') ? 'topology summary' : 'topology'; + case 'health': + return 'health summary'; + default: + if (action) return formatIdentifierLabel(action, { maxLength: 28 }); + return resourceType + ? `query ${formatIdentifierLabel(resourceType, { maxLength: 22 })}` + : 'query resources'; + } +}; + +const formatAlertsInputSummary = (record: Record) => { + const action = stringField(record, ['action']).toLowerCase(); + const severity = stringField(record, ['severity', 'level']); + const resource = stringField(record, ['resource', 'resource_id', 'resourceId', 'name']); + + if (action === 'list') { + return severity + ? `list ${formatIdentifierLabel(severity, { maxLength: 18 })} alerts` + : 'list active alerts'; + } + if (action === 'get') { + return resource ? `get alert for ${inlineValue(resource, 18)}` : 'get alert'; + } + return action ? formatIdentifierLabel(action, { maxLength: 28 }) : 'read alerts'; +}; + +const formatStructuredInputSummary = ( + record: Record, + toolName?: string, + rawInput?: string, +) => { + if (Object.keys(record).length === 0) { + return formatPartialRawInputSummary(rawInput, toolName) || 'request'; + } + + const tool = normalizedToolName(toolName); + if (tool === 'read') { + return formatPulseReadInputSummary(record) || 'read resource'; + } + if (tool === 'run_command' || tool === 'control') { + return formatCommandSummary(record) || 'run command'; + } + if (tool === 'query') { + return formatQueryInputSummary(record); + } + if (tool === 'alerts') { + return formatAlertsInputSummary(record); + } + if (typeof record.action === 'string' && record.action.trim()) { + return formatIdentifierLabel(record.action, { maxLength: 28 }); + } + if (typeof record.command === 'string' && record.command.trim()) { + return formatIdentifierLabel(record.command, { maxLength: 28 }); + } + return 'request'; +}; + +const parseStructuredInputSummary = (input: string, toolName?: string, rawInput?: string) => { + const trimmed = input.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return formatStructuredInputSummary(parsed as Record, toolName, rawInput); + } + } catch { + // Keep trying provider-style function-call input below. + } + + const functionCall = parseFunctionStyleToolInput(trimmed); + if (functionCall) { + return formatStructuredInputSummary(functionCall.args, functionCall.name, rawInput); + } + + return null; +}; + +export const parseToolInputSummary = (input: string, toolName?: string, rawInput?: string) => { + const trimmed = input.trim(); + if (!trimmed) return ''; + + const directSummary = parseStructuredInputSummary(trimmed, toolName, rawInput); + if (directSummary) { + return directSummary; + } + + if (rawInput && rawInput.trim() && rawInput.trim() !== trimmed) { + const rawSummary = parseStructuredInputSummary(rawInput, toolName); + if (rawSummary && !isPlaceholderToolInputSummary(rawSummary)) { + return rawSummary; + } + } + + return formatIdentifierLabel(trimmed, { maxLength: 28 }); +}; + +export const toolValueText = (value: unknown) => { + if (typeof value === 'string') return value; + if (value === null || value === undefined) return ''; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +};