From f4f11d667d146346b10de05b8fb84de46178d275 Mon Sep 17 00:00:00 2001 From: wannes depuydt Date: Wed, 8 Apr 2026 16:25:25 +0200 Subject: [PATCH] feat(ui): improve grouped tool-call summaries and status rendering --- docs/TOOL_CALL_GROUPING.md | 132 ++ .../Markdown/ConsecutiveDetailsGroup.svelte | 355 ++- .../Messages/Markdown/MarkdownTokens.svelte | 88 +- .../components/common/ToolCallDisplay.svelte | 1953 +++++++++++++++-- src/lib/utils/toolCallInlineStyles.ts | 12 + src/lib/utils/toolCallPresentation.ts | 717 ++++++ 6 files changed, 3071 insertions(+), 186 deletions(-) create mode 100644 docs/TOOL_CALL_GROUPING.md create mode 100644 src/lib/utils/toolCallInlineStyles.ts create mode 100644 src/lib/utils/toolCallPresentation.ts diff --git a/docs/TOOL_CALL_GROUPING.md b/docs/TOOL_CALL_GROUPING.md new file mode 100644 index 0000000000..ab145ad145 --- /dev/null +++ b/docs/TOOL_CALL_GROUPING.md @@ -0,0 +1,132 @@ +# Tool Call Grouping System + +This document explains how grouped tool-call labels (for example "Reviewed Chats" and "Managed memory") are matched, ordered, and rendered. + +## Where Group Ordering Lives + +The group matching and ordering are defined in: + +- `src/lib/utils/toolCallPresentation.ts` +- Constant: `TOOL_COMBINATION_RULES` + +Rules are evaluated in ascending `order`. + +- Smaller `order` = higher priority (matched first) +- First matching rule wins + +## Match System (Easy To Edit) + +Each rule supports these match clauses: + +- `allOf`: every tool name listed must be present +- `anyOf`: at least one tool name listed must be present +- `noneOf`: none of the listed tool names may be present +- `onlyOf`: all tools in the group must be in this allow-list (no extra tools) + +Rule shape: + +```ts +{ + id: 'unique.rule.id', + order: 100, + iconKey: 'chat', + pendingPrefix: 'Reviewing Chats', + donePrefix: 'Reviewed Chats', + match: { + allOf: ['search_chats'], + anyOf: ['view_chat'], + noneOf: ['delete_memory'], + onlyOf: ['search_chats', 'view_chat'] + }, + showDetailList: false +} +``` + +`onlyOf` is optional. Use it when a rule should only match a bounded set of tools. + +## Named Group Behavior + +When a rule matches, the grouped summary uses the rule prefix and icon. + +By default, named groups hide the trailing raw tool list (for example "search_chats, view_chat"). + +- Default is `showDetailList: false` +- Set `showDetailList: true` only when you explicitly want to append the raw tool names + +## Add A New Group + +1. Open `src/lib/utils/toolCallPresentation.ts`. +2. Add a rule to `TOOL_COMBINATION_RULES`. +3. Choose an `order` that places it before/after related rules. +4. Ensure `id` is unique. +5. Set prefixes and icon. +6. Set `showDetailList` only if needed. + +## Practical Ordering Strategy + +Use these ranges to keep rules organized: + +- `100-299`: web and knowledge research +- `300-499`: chats and notes review +- `500-699`: channels and media +- `700-999`: broad fallback groups (memory/notes/code) + +Put narrow/specific rules before broad/fallback rules. + +## Examples + +### 1) Web search + code execution (but not note viewing) + +```ts +{ + id: 'web.search_and_execute_code', + order: 150, + iconKey: 'terminal', + pendingPrefix: 'Researching and Executing', + donePrefix: 'Researched and Executed', + match: { + allOf: ['search_web', 'execute_code'], + noneOf: ['view_note'], + onlyOf: ['search_web', 'fetch_url', 'execute_code'] + } +} +``` + +### 2) New "Reviewed Tasks" group + +```ts +{ + id: 'tasks.review', + order: 350, + iconKey: 'note', + pendingPrefix: 'Reviewing Tasks', + donePrefix: 'Reviewed Tasks', + match: { + allOf: ['search_tasks'], + anyOf: ['view_task'] + } +} +``` + +### 3) Broad fallback for any task activity + +```ts +{ + id: 'tasks.manage', + order: 950, + iconKey: 'note', + pendingPrefix: 'Managing tasks', + donePrefix: 'Managed tasks', + match: { + anyOf: ['search_tasks', 'view_task', 'create_task', 'update_task', 'delete_task'] + } +} +``` + +## Rendering Location + +The grouped summary UI reads these values in: + +- `src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte` + +It now suppresses the trailing list whenever a named group is active unless `showDetailList` is set to `true` in the matched rule. diff --git a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte index 598511eb25..1da1adb6d0 100644 --- a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte +++ b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte @@ -1,20 +1,42 @@
- + {#if open} +
+ {/if} + {#if open}
@@ -165,8 +466,8 @@ {#each allEmbeds as embedItem, idx}
import { decode } from 'html-entities'; - import { onMount, getContext } from 'svelte'; - const i18n = getContext('i18n'); + import { getContext } from 'svelte'; + import type { Readable } from 'svelte/store'; + const i18n = getContext) => string }>>('i18n'); import fileSaver from 'file-saver'; const { saveAs } = fileSaver; @@ -20,6 +21,7 @@ import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Download from '$lib/components/icons/Download.svelte'; + import CheckCircle from '$lib/components/icons/CheckCircle.svelte'; import ConsecutiveDetailsGroup from './ConsecutiveDetailsGroup.svelte'; import HtmlToken from './HTMLToken.svelte'; @@ -30,7 +32,7 @@ export let tokens: Token[]; export let top = true; export let attributes = {}; - export let sourceIds = []; + export let sourceIds: string[] = []; export let done = true; @@ -53,21 +55,24 @@ return 'h' + depth; }; - const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls', 'reasoning', 'code_interpreter']); + const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls']); + type DetailGroupItem = Token & { attributes?: { type?: string; done?: string }; text?: string }; + type DetailGroupToken = { type: 'detail_group'; items: DetailGroupItem[]; groupClosed: boolean }; const isGroupableDetailToken = (token: Token & { attributes?: { type?: string } }) => { return token?.type === 'details' && GROUPABLE_DETAIL_TYPES.has(token?.attributes?.type ?? ''); }; const getDisplayTokens = (tokenList: Token[] = []) => { - const displayTokens = []; - let detailGroup = []; + const displayTokens: Array = []; + let detailGroup: DetailGroupItem[] = []; - const flushDetailGroup = () => { + const flushDetailGroup = (groupClosed: boolean) => { if (detailGroup.length > 1) { displayTokens.push({ type: 'detail_group', - items: [...detailGroup] + items: [...detailGroup], + groupClosed }); } else if (detailGroup.length === 1) { displayTokens.push(detailGroup[0]); @@ -80,17 +85,44 @@ if (isGroupableDetailToken(token)) { detailGroup.push(token); } else { - flushDetailGroup(); + flushDetailGroup(true); displayTokens.push(token); } } - flushDetailGroup(); + flushDetailGroup(false); return displayTokens; }; - const getDetailTextContent = (token) => { + const hasLaterToolCall = (items: DetailGroupItem[], index: number): boolean => { + for (let i = index + 1; i < items.length; i += 1) { + if (items[i]?.attributes?.type === 'tool_calls') { + return true; + } + } + + return false; + }; + + const getNormalizedGroupedToolDone = ( + items: DetailGroupItem[], + index: number, + rawDone: string | undefined, + groupClosed: boolean + ): string => { + if (rawDone === 'true') { + return 'true'; + } + + if (hasLaterToolCall(items, index) || groupClosed) { + return 'true'; + } + + return rawDone ?? 'false'; + }; + + const getDetailTextContent = (token: DetailGroupItem): string => { return decode(token?.text || '') .replace(/.*?<\/summary>/gi, '') .trim(); @@ -370,20 +402,33 @@ -
+
{#each token.items as detailToken, detailIdx} {@const textContent = getDetailTextContent(detailToken)} {#if detailToken?.attributes?.type === 'tool_calls'} + {@const toolDone = getNormalizedGroupedToolDone( + token.items, + detailIdx, + detailToken?.attributes?.done, + token.groupClosed === true + )} + + {#if hasLaterToolCall(token.items, detailIdx) || (token.groupClosed === true && detailIdx === token.items.length - 1)} +
+
+
+ {/if} {:else if textContent.length > 0} {/if} {/each} + + {#if token.groupClosed === true} +
+
+ +
+
{$i18n.t('Done')}
+
+ {/if}
{:else if token.type === 'details'} @@ -428,7 +482,7 @@ {:else if textContent.length > 0} @@ -547,7 +601,7 @@ {onSourceClick} /> {:else if token.type === 'space'} -
+
{:else} {console.log('Unknown token', token)} {/if} diff --git a/src/lib/components/common/ToolCallDisplay.svelte b/src/lib/components/common/ToolCallDisplay.svelte index e1c5a3bfb4..6302a0f896 100644 --- a/src/lib/components/common/ToolCallDisplay.svelte +++ b/src/lib/components/common/ToolCallDisplay.svelte @@ -1,10 +1,9 @@
@@ -92,13 +1183,13 @@
- {attributes.name} + {toolName}
{#each embeds as embed, idx}
{:else} - -
{ - open = !open; - }} - > + {#if grouped}
- - {#if isExecuting} -
- -
- {:else if isDone} -
- -
- {:else} -
- -
- {/if} - - -
- - {attributes.name} - - -
- - -
- {#if open} - - {:else} - - {/if} -
-
-
- - {#if open} -
-
- - {#if args} -
-
- {$i18n.t('Input')} -
- - {#if parsedArgs} -
- {#each Object.entries(parsedArgs) as [key, value]} -
- {key} - {typeof value === 'object' ? JSON.stringify(value) : value} -
- {/each} + +
{ + open = !open; + }} + > +
+
+ {#if isExecuting} + + {:else if isDone} +
+
{:else} -
- +
+
{/if}
- {/if} - - {#if isDone && result} -
-
- {$i18n.t('Output')} +
+
+
+ {toolName} +
-
- {#if typeof parsedResult === 'object' && parsedResult !== null} - + +
+ {#if isExecuting} + {toolPresentation.runningLabel} · {inputCountLabel} + {:else if isDone} + {toolPresentation.doneLabel} · {inputCountLabel} {:else} - {@const resultStr = String(parsedResult)} - {@const isTruncated = resultStr.length > RESULT_PREVIEW_LIMIT && !expandedResult} -
{isTruncated
-											? resultStr.slice(0, RESULT_PREVIEW_LIMIT)
-											: resultStr}
- {#if isTruncated} - - {/if} + {$i18n.t('Ready to run')} · {toolName} {/if}
- {/if} + +
+ {#if open} + + {:else} + + {/if} +
+
+ + {#if open} +
+
+ + {#if args} +
+
+ {toolPresentation.inputLabel} +
+ + {#if parsedArgs} + {#if isNotesTool && noteInputPreview} +
+
+ {noteInputPreview.title} +
+
+ {noteInputPreview.content} +
+
+ {:else if isCodeTool && codeInputSnippet} +
+
+ Code +
+
{codeInputSnippet}
+
+ {:else} +
+ {#each argRows as row} + {#if isPrimaryPayloadArg(getArgRowKey(row))} +
+ {getArgRowValue(row)} +
+ {:else} +
+ {getArgRowKey(row)} + {getArgRowValue(row)} +
+ {/if} + {/each} +
+ {/if} + {:else} +
+ +
+ {/if} +
+ {/if} + + + {#if isDone && result} +
+
+
+ {toolPresentation.outputLabel} +
+ {#if showResultCount} +
+ {$i18n.t('{{COUNT}} chars', { COUNT: resultLength.toLocaleString() })} +
+ {/if} +
+
+ {#if failureInfo} +
+
+
+ {failureInfo.title} +
+
+ {failureInfo.message} +
+
+ {#if failureInfo.details} +
{failureInfo.details}
+ {/if} +
+ {:else if isCurrentTimestampTool && currentTimestampDisplay} +
+
+
Current Time
+
{currentTimestampDisplay.dateLabel}
+
{currentTimestampDisplay.timeLabel}
+
+
+ {:else if isCalculateTimestampTool && calculateTimestampDiagram} +
+
+ {#if calculateTimestampDiagram.isFuture} +
+
Result
+
{calculateTimestampDiagram.targetLabel}
+
+
+
{calculateTimestampDiagram.relativeLabel}
+
+
+
+
Current Date
+
{calculateTimestampDiagram.currentLabel}
+
+ {:else} +
+
Current Date
+
{calculateTimestampDiagram.currentLabel}
+
+
+
{calculateTimestampDiagram.relativeLabel}
+
+
+
+
Result
+
{calculateTimestampDiagram.targetLabel}
+
+ {/if} +
+
+ {:else if isSearchTool && hasSearchOutput} + {#if searchWebResults.length > 0} +
+ {#each searchWebResults as item, idx} + {@const resultLink = getSearchResultLink(item)} + {@const resultTitle = getSearchResultTitle(item)} + {@const resultSnippet = getSearchResultSnippet(item)} +
+
+ Result {idx + 1} +
+ {#if resultLink} + + {resultLink} + + {/if} + {#if resultTitle} +
+ {resultTitle} +
+ {/if} + {#if resultSnippet} +
+ {resultSnippet} +
+ {/if} +
+ {/each} +
+ {:else} +
+ {#each searchFallbackLines as line, idx} +
+ {#if line === 'No matches found.'} +
+ {line} +
+ {:else} +
+ Result {idx + 1} +
+
+ {line} +
+ {/if} +
+ {/each} +
+ {/if} + {:else if isMemoryTool && memoryItems.length > 0} +
+ {#each memoryItems as memoryItem, idx} +
+
+ {getMemoryItemTitle(memoryItem, idx)} +
+
+ {getMemoryItemBody(memoryItem)} +
+
+ {/each} +
+ {:else if isCodeTool && codeOutputBlocks.length > 0} +
+ {#each codeOutputBlocks as block} +
+
+ {block.label} +
+
{block.value}
+
+ {/each} +
+ {:else if isNotesTool && notePreview} +
+
+ {notePreview.title} +
+
+ {notePreview.content} +
+
+ {:else if isKnowledgeTool && knowledgeItems.length > 0} +
+ {#each knowledgeItems as item} +
+
+ {item.title} +
+ {#if item.subtitle} +
+ {item.subtitle} +
+ {/if} + {#if item.content} +
+ {item.content} +
+ {/if} +
+ {/each} +
+ {:else if isKnowledgeTool && knowledgeAnswer} +
+
+ Answer +
+
+ {knowledgeAnswer} +
+
+ {:else if isKnowledgeTool && knowledgeDocument} +
+
+ Document +
+
+ {knowledgeDocument} +
+
+ {:else if useCompactResult} +
+
+ {getCompactResultText(parsedResult)} +
+
+ {:else if typeof parsedResult === 'object' && parsedResult !== null} +
{JSON.stringify(parsedResult, null, 2)}
+ {:else} + {@const resultStr = String(parsedResult)} + {@const isTruncated = resultStr.length > RESULT_PREVIEW_LIMIT && !expandedResult} +
{isTruncated
+												? resultStr.slice(0, RESULT_PREVIEW_LIMIT)
+												: resultStr}
+ {#if isTruncated} + + {/if} + {/if} +
+
+ {/if} +
+
+ {/if}
+ {:else} +
+ +
{ + open = !open; + }} + > +
+
+ {#if isExecuting} + + {:else if isDone} +
+ +
+ {:else} +
+ +
+ {/if} +
+ +
+ {toolName} +
+ +
+ {#if open} + + {:else} + + {/if} +
+
+
+ + {#if open} +
+ {/if} +
+ + {#if open} +
+
+ + {#if args} +
+
+ {toolPresentation.inputLabel} +
+ + {#if parsedArgs} + {#if isNotesTool && noteInputPreview} +
+
+ {noteInputPreview.title} +
+
+ {noteInputPreview.content} +
+
+ {:else if isCodeTool && codeInputSnippet} +
+
+ Code +
+
{codeInputSnippet}
+
+ {:else} +
+ {#each argRows as row} + {#if isPrimaryPayloadArg(getArgRowKey(row))} +
+ {getArgRowValue(row)} +
+ {:else} +
+ {getArgRowKey(row)} + {getArgRowValue(row)} +
+ {/if} + {/each} +
+ {/if} + {:else} +
+ +
+ {/if} +
+ {/if} + + + {#if isDone && result} +
+
+
+ {toolPresentation.outputLabel} +
+ {#if showResultCount} +
+ {$i18n.t('{{COUNT}} chars', { COUNT: resultLength.toLocaleString() })} +
+ {/if} +
+
+ {#if failureInfo} +
+
+
+ {failureInfo.title} +
+
+ {failureInfo.message} +
+
+ {#if failureInfo.details} +
{failureInfo.details}
+ {/if} +
+ {:else if isCurrentTimestampTool && currentTimestampDisplay} +
+
+
Current Time
+
{currentTimestampDisplay.dateLabel}
+
{currentTimestampDisplay.timeLabel}
+
+
+ {:else if isCalculateTimestampTool && calculateTimestampDiagram} +
+
+ {#if calculateTimestampDiagram.isFuture} +
+
Result
+
{calculateTimestampDiagram.targetLabel}
+
+
+
{calculateTimestampDiagram.relativeLabel}
+
+
+
+
Current Date
+
{calculateTimestampDiagram.currentLabel}
+
+ {:else} +
+
Current Date
+
{calculateTimestampDiagram.currentLabel}
+
+
+
{calculateTimestampDiagram.relativeLabel}
+
+
+
+
Result
+
{calculateTimestampDiagram.targetLabel}
+
+ {/if} +
+
+ {:else if isSearchTool && hasSearchOutput} + {#if searchWebResults.length > 0} +
+ {#each searchWebResults as item, idx} + {@const resultLink = getSearchResultLink(item)} + {@const resultTitle = getSearchResultTitle(item)} + {@const resultSnippet = getSearchResultSnippet(item)} +
+
+ Result {idx + 1} +
+ {#if resultLink} + + {resultLink} + + {/if} + {#if resultTitle} +
+ {resultTitle} +
+ {/if} + {#if resultSnippet} +
+ {resultSnippet} +
+ {/if} +
+ {/each} +
+ {:else} +
+ {#each searchFallbackLines as line, idx} +
+ {#if line === 'No matches found.'} +
+ {line} +
+ {:else} +
+ Result {idx + 1} +
+
+ {line} +
+ {/if} +
+ {/each} +
+ {/if} + {:else if isMemoryTool && memoryItems.length > 0} +
+ {#each memoryItems as memoryItem, idx} +
+
+ {getMemoryItemTitle(memoryItem, idx)} +
+
+ {getMemoryItemBody(memoryItem)} +
+
+ {/each} +
+ {:else if isCodeTool && codeOutputBlocks.length > 0} +
+ {#each codeOutputBlocks as block} +
+
+ {block.label} +
+
{block.value}
+
+ {/each} +
+ {:else if isNotesTool && notePreview} +
+
+ {notePreview.title} +
+
+ {notePreview.content} +
+
+ {:else if isKnowledgeTool && knowledgeItems.length > 0} +
+ {#each knowledgeItems as item} +
+
+ {item.title} +
+ {#if item.subtitle} +
+ {item.subtitle} +
+ {/if} + {#if item.content} +
+ {item.content} +
+ {/if} +
+ {/each} +
+ {:else if isKnowledgeTool && knowledgeAnswer} +
+
+ Answer +
+
+ {knowledgeAnswer} +
+
+ {:else if isKnowledgeTool && knowledgeDocument} +
+
+ Document +
+
+ {knowledgeDocument} +
+
+ {:else if useCompactResult} +
+
+ {getCompactResultText(parsedResult)} +
+
+ {:else if typeof parsedResult === 'object' && parsedResult !== null} +
{JSON.stringify(parsedResult, null, 2)}
+ {:else} + {@const resultStr = String(parsedResult)} + {@const isTruncated = resultStr.length > RESULT_PREVIEW_LIMIT && !expandedResult} +
{isTruncated
+											? resultStr.slice(0, RESULT_PREVIEW_LIMIT)
+											: resultStr}
+ {#if isTruncated} + + {/if} + {/if} +
+
+ {/if} +
+
+ {/if} {/if} {/if} {#if isDone} - {#if typeof files === 'object'} - {#each files ?? [] as file, idx} + {#if Array.isArray(files)} + {#each files as file} {#if typeof file === 'string'} {#if file.startsWith('data:image/')} - Image + Image {/if} - {:else if typeof file === 'object'} - {#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url} - Image + {:else if isImageFileObject(file)} + {#if file.url} + Image {/if} {/if} {/each} {/if} {/if}
+ + diff --git a/src/lib/utils/toolCallInlineStyles.ts b/src/lib/utils/toolCallInlineStyles.ts new file mode 100644 index 0000000000..12b1d26803 --- /dev/null +++ b/src/lib/utils/toolCallInlineStyles.ts @@ -0,0 +1,12 @@ +export const INLINE_TOOL_TRIGGER_CLASS = + 'w-full max-w-full text-left cursor-pointer py-1 pl-[10px] ml-[10px] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition'; + +export const INLINE_TOOL_ROW_CLASS = 'flex items-center gap-1.5 min-w-0'; + +export const INLINE_TOOL_TITLE_CLASS = 'line-clamp-1 min-w-0 text-gray-600 dark:text-gray-300'; + +export const INLINE_TOOL_CHEVRON_CLASS = + 'flex shrink-0 self-center text-gray-400 dark:text-gray-500 translate-y-[1px]'; + +// Trigger has ml-[10px] + pl-[10px], icon center is +8px from content start => 28px total. +export const INLINE_TOOL_ICON_CENTER_OFFSET_CLASS = 'ml-[27px]'; diff --git a/src/lib/utils/toolCallPresentation.ts b/src/lib/utils/toolCallPresentation.ts new file mode 100644 index 0000000000..bd03d4f1e5 --- /dev/null +++ b/src/lib/utils/toolCallPresentation.ts @@ -0,0 +1,717 @@ +export type ToolIconKey = + | 'globe' + | 'link' + | 'photo' + | 'terminal' + | 'database' + | 'note' + | 'chat' + | 'channels' + | 'book' + | 'clock' + | 'sparkles' + | 'document'; + +export type ToolSemantic = + | 'search_web' + | 'fetch_url' + | 'image' + | 'code' + | 'memory' + | 'notes' + | 'chats' + | 'channels' + | 'knowledge' + | 'time' + | 'skills' + | 'generic'; + + +export type ToolPresentation = { + rawName: string; + displayName: string; + semantic: ToolSemantic; + iconKey: ToolIconKey; + runningLabel: string; + doneLabel: string; + inputLabel: string; + outputLabel: string; +}; + +const EXACT_TOOL_MAP: Record> = { + get_current_timestamp: { + semantic: 'time', + iconKey: 'clock', + runningLabel: 'Checking Time', + doneLabel: 'Checked Time', + inputLabel: 'Time Request', + outputLabel: 'Timestamp', + }, + calculate_timestamp: { + semantic: 'time', + iconKey: 'clock', + runningLabel: 'Calculating Time', + doneLabel: 'Calculated Time', + inputLabel: 'Time Offsets', + outputLabel: 'Calculated Timestamp', + }, + search_web: { + semantic: 'search_web', + iconKey: 'globe', + runningLabel: 'Searching the web', + doneLabel: 'Searched the web', + inputLabel: 'Search Query', + outputLabel: 'Search Results', + }, + fetch_url: { + semantic: 'fetch_url', + iconKey: 'link', + runningLabel: 'Fetching Web Page', + doneLabel: 'Fetched Web Page', + inputLabel: 'URL', + outputLabel: 'Page Content', + }, + generate_image: { + semantic: 'image', + iconKey: 'photo', + runningLabel: 'Generating Image', + doneLabel: 'Generated Image', + inputLabel: 'Image Prompt', + outputLabel: 'Generated Asset', + }, + edit_image: { + semantic: 'image', + iconKey: 'photo', + runningLabel: 'Editing Image', + doneLabel: 'Edited Image', + inputLabel: 'Edit Request', + outputLabel: 'Edited Asset', + }, + execute_code: { + semantic: 'code', + iconKey: 'terminal', + runningLabel: 'Executing code', + doneLabel: 'Executed code', + inputLabel: 'Code', + outputLabel: 'Execution Result', + }, + search_memories: { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Searching memories', + doneLabel: 'Searched memories', + inputLabel: 'Memory Query', + outputLabel: 'Memory Matches', + }, + add_memory: { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Saving memory', + doneLabel: 'Saved memory', + inputLabel: 'Memory to Save', + outputLabel: 'Stored Memory', + }, + replace_memory_content: { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Updating memory', + doneLabel: 'Updated memory', + inputLabel: 'Memory Update', + outputLabel: 'Memory Update Result', + }, + delete_memory: { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Deleting memory', + doneLabel: 'Deleted memory', + inputLabel: 'Memory Target', + outputLabel: 'Delete Result', + }, + list_memories: { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Listing memories', + doneLabel: 'Listed memories', + inputLabel: 'List Filters', + outputLabel: 'Memory List', + }, + search_notes: { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Searching notes', + doneLabel: 'Searched notes', + inputLabel: 'Note Query', + outputLabel: 'Note Matches', + }, + view_note: { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Opening note', + doneLabel: 'Opened note', + inputLabel: 'Note Target', + outputLabel: 'Note Content', + }, + write_note: { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Writing note', + doneLabel: 'Wrote note', + inputLabel: 'Note to Save', + outputLabel: 'Saved Note', + }, + replace_note_content: { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Updating note', + doneLabel: 'Updated note', + inputLabel: 'Note Update', + outputLabel: 'Note Update Result', + }, + search_chats: { + semantic: 'chats', + iconKey: 'chat', + runningLabel: 'Searching Chats', + doneLabel: 'Searched Chats', + inputLabel: 'Chat Query', + outputLabel: 'Chat Matches', + }, + view_chat: { + semantic: 'chats', + iconKey: 'chat', + runningLabel: 'Opening Chat', + doneLabel: 'Opened Chat', + inputLabel: 'Chat Target', + outputLabel: 'Chat Transcript', + }, + search_channels: { + semantic: 'channels', + iconKey: 'channels', + runningLabel: 'Searching Channels', + doneLabel: 'Searched Channels', + inputLabel: 'Channel Query', + outputLabel: 'Channel Matches', + }, + search_channel_messages: { + semantic: 'channels', + iconKey: 'channels', + runningLabel: 'Searching Messages', + doneLabel: 'Searched Messages', + inputLabel: 'Message Query', + outputLabel: 'Message Matches', + }, + view_channel_message: { + semantic: 'channels', + iconKey: 'channels', + runningLabel: 'Opening Message', + doneLabel: 'Opened Message', + inputLabel: 'Message Target', + outputLabel: 'Message Detail', + }, + view_channel_thread: { + semantic: 'channels', + iconKey: 'channels', + runningLabel: 'Opening Thread', + doneLabel: 'Opened Thread', + inputLabel: 'Thread Target', + outputLabel: 'Thread Detail', + }, + list_knowledge_bases: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Listing Knowledge Bases', + doneLabel: 'Listed Knowledge Bases', + inputLabel: 'Knowledge Filter', + outputLabel: 'Knowledge Bases', + }, + search_knowledge_bases: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Searching Knowledge Bases', + doneLabel: 'Searched Knowledge Bases', + inputLabel: 'Knowledge Query', + outputLabel: 'Knowledge Matches', + }, + search_knowledge_files: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Searching Knowledge Files', + doneLabel: 'Searched Knowledge Files', + inputLabel: 'File Query', + outputLabel: 'File Matches', + }, + view_file: { + semantic: 'knowledge', + iconKey: 'document', + runningLabel: 'Opening File', + doneLabel: 'Opened File', + inputLabel: 'File Target', + outputLabel: 'File Content', + }, + view_knowledge_file: { + semantic: 'knowledge', + iconKey: 'document', + runningLabel: 'Opening Knowledge File', + doneLabel: 'Opened Knowledge File', + inputLabel: 'File Target', + outputLabel: 'File Content', + }, + list_knowledge: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Listing Knowledge', + doneLabel: 'Listed Knowledge', + inputLabel: 'Knowledge Filter', + outputLabel: 'Knowledge List', + }, + query_knowledge_files: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Querying Knowledge Files', + doneLabel: 'Queried Knowledge Files', + inputLabel: 'Knowledge Query', + outputLabel: 'Knowledge Answer', + }, + query_knowledge_bases: { + semantic: 'knowledge', + iconKey: 'book', + runningLabel: 'Querying Knowledge Bases', + doneLabel: 'Queried Knowledge Bases', + inputLabel: 'Knowledge Query', + outputLabel: 'Knowledge Answer', + }, + view_skill: { + semantic: 'skills', + iconKey: 'sparkles', + runningLabel: 'Loading Skill', + doneLabel: 'Loaded Skill', + inputLabel: 'Skill Target', + outputLabel: 'Skill Content', + } +}; + +function toTitleCaseWords(value: string): string { + return value + .replace(/[_.-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .split(/\s+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') + .trim(); +} + +function inferFallback(rawName: string): Omit { + const normalized = rawName.toLowerCase(); + + if (/search/.test(normalized) && /web|url|http|browse/.test(normalized)) { + return { + semantic: 'search_web', + iconKey: 'globe', + runningLabel: 'Searching the web', + doneLabel: 'Searched the web', + inputLabel: 'Search Query', + outputLabel: 'Search Results', + }; + } + + if (/fetch|url|http|crawl/.test(normalized)) { + return { + semantic: 'fetch_url', + iconKey: 'link', + runningLabel: 'Fetching Content', + doneLabel: 'Fetched Content', + inputLabel: 'URL', + outputLabel: 'Content', + }; + } + + if (/code|python|terminal|exec|command|script/.test(normalized)) { + return { + semantic: 'code', + iconKey: 'terminal', + runningLabel: 'Executing code', + doneLabel: 'Executed code', + inputLabel: 'Code', + outputLabel: 'Execution Result', + }; + } + + if (/memories|memory/.test(normalized)) { + if (/search|find|query/.test(normalized)) { + return { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Searching memories', + doneLabel: 'Searched memories', + inputLabel: 'Memory Query', + outputLabel: 'Memory Matches', + }; + } + + if (/list/.test(normalized)) { + return { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Listing memories', + doneLabel: 'Listed memories', + inputLabel: 'List Filters', + outputLabel: 'Memory List', + }; + } + + if (/delete|remove/.test(normalized)) { + return { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Deleting memory', + doneLabel: 'Deleted memory', + inputLabel: 'Memory Target', + outputLabel: 'Delete Result', + }; + } + + if (/replace|update|edit/.test(normalized)) { + return { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Updating memory', + doneLabel: 'Updated memory', + inputLabel: 'Memory Update', + outputLabel: 'Memory Update Result', + }; + } + + return { + semantic: 'memory', + iconKey: 'database', + runningLabel: 'Saving memory', + doneLabel: 'Saved memory', + inputLabel: 'Memory Content', + outputLabel: 'Stored Memory', + }; + } + + if (/notes|note/.test(normalized)) { + if (/search|find|query/.test(normalized)) { + return { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Searching notes', + doneLabel: 'Searched notes', + inputLabel: 'Note Query', + outputLabel: 'Note Matches', + }; + } + + if (/view|read|open/.test(normalized)) { + return { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Opening note', + doneLabel: 'Opened note', + inputLabel: 'Note Target', + outputLabel: 'Note Content', + }; + } + + if (/replace|update|edit/.test(normalized)) { + return { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Updating note', + doneLabel: 'Updated note', + inputLabel: 'Note Update', + outputLabel: 'Note Update Result', + }; + } + + if (/delete|remove/.test(normalized)) { + return { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Deleting note', + doneLabel: 'Deleted note', + inputLabel: 'Note Target', + outputLabel: 'Delete Result', + }; + } + + return { + semantic: 'notes', + iconKey: 'note', + runningLabel: 'Writing note', + doneLabel: 'Wrote note', + inputLabel: 'Note Draft', + outputLabel: 'Saved Note', + }; + } + + if (/knowledge|note|memory|chat|channel|file|document/.test(normalized)) { + return { + semantic: 'generic', + iconKey: 'document', + runningLabel: 'Running Tool', + doneLabel: 'Completed Tool', + inputLabel: 'Input', + outputLabel: 'Output', + }; + } + + return { + semantic: 'generic', + iconKey: 'sparkles', + runningLabel: 'Running Tool', + doneLabel: 'Completed Tool', + inputLabel: 'Input', + outputLabel: 'Output', + }; +} + +export function getToolPresentation(name: string | undefined): ToolPresentation { + const rawName = (name ?? 'tool').trim() || 'tool'; + const displayName = toTitleCaseWords(rawName); + const fromMap = EXACT_TOOL_MAP[rawName]; + const inferred = fromMap ?? inferFallback(rawName); + + return { + rawName, + displayName, + ...inferred + }; +} + +export type ToolCombination = { + iconKey: ToolIconKey; + prefix: string; + showDetailList: boolean; + matchedRuleId: string; +}; + +export type ToolCombinationMatch = { + allOf?: string[]; + anyOf?: string[]; + noneOf?: string[]; + onlyOf?: string[]; +}; + +export type ToolCombinationRule = { + id: string; + order: number; + iconKey: ToolIconKey; + pendingPrefix: string; + donePrefix: string; + match: ToolCombinationMatch; + showDetailList?: boolean; +}; + +// Rules are evaluated by ascending order. Lower order wins. +export const TOOL_COMBINATION_RULES: ToolCombinationRule[] = [ + { + id: 'web.search_and_fetch', + order: 100, + iconKey: 'globe', + pendingPrefix: 'Searching the Web', + donePrefix: 'Searched the Web', + match: { allOf: ['search_web', 'fetch_url'] } + }, + { + id: 'web.search_and_execute_code', + order: 150, + iconKey: 'terminal', + pendingPrefix: 'Researching and Executing', + donePrefix: 'Researched and Executed', + match: { + allOf: ['search_web', 'execute_code'], + noneOf: ['view_note'], + onlyOf: ['search_web', 'fetch_url', 'execute_code'] + } + }, + { + id: 'knowledge.research', + order: 200, + iconKey: 'book', + pendingPrefix: 'Researching Knowledge', + donePrefix: 'Researched Knowledge', + match: { + anyOf: ['search_knowledge_bases', 'search_knowledge_files'], + allOf: ['query_knowledge_bases'] + } + }, + { + id: 'knowledge.search_and_view', + order: 210, + iconKey: 'book', + pendingPrefix: 'Researching Knowledge', + donePrefix: 'Researched Knowledge', + match: { + anyOf: ['search_knowledge_bases', 'search_knowledge_files'], + allOf: ['view_file'] + } + }, + { + id: 'knowledge.search_and_view_knowledge_file', + order: 220, + iconKey: 'book', + pendingPrefix: 'Researching Knowledge', + donePrefix: 'Researched Knowledge', + match: { + anyOf: ['search_knowledge_bases', 'search_knowledge_files'], + allOf: ['view_knowledge_file'] + } + }, + { + id: 'notes.review', + order: 300, + iconKey: 'note', + pendingPrefix: 'Reviewing Notes', + donePrefix: 'Reviewed Notes', + match: { + allOf: ['search_notes'], + anyOf: ['view_note', 'write_note', 'replace_note_content'] + } + }, + { + id: 'chats.review', + order: 400, + iconKey: 'chat', + pendingPrefix: 'Reviewing Chats', + donePrefix: 'Reviewed Chats', + match: { allOf: ['search_chats', 'view_chat'] } + }, + { + id: 'channels.explore', + order: 500, + iconKey: 'channels', + pendingPrefix: 'Exploring Channels', + donePrefix: 'Explored Channels', + match: { + allOf: ['search_channels'], + anyOf: ['search_channel_messages', 'view_channel_message', 'view_channel_thread'] + } + }, + { + id: 'images.create_or_edit', + order: 600, + iconKey: 'photo', + pendingPrefix: 'Creating Images', + donePrefix: 'Created Images', + match: { anyOf: ['generate_image', 'edit_image'] } + }, + { + id: 'code.execute', + order: 700, + iconKey: 'terminal', + pendingPrefix: 'Executing Code', + donePrefix: 'Executed Code', + match: { onlyOf: ['execute_code'] } + }, + { + id: 'memory.manage', + order: 800, + iconKey: 'database', + pendingPrefix: 'Managing Memory', + donePrefix: 'Managed Memory', + match: { + onlyOf: [ + 'search_memories', + 'list_memories', + 'add_memory', + 'replace_memory_content', + 'delete_memory' + ] + } + }, + { + id: 'notes.manage', + order: 900, + iconKey: 'note', + pendingPrefix: 'Managing Notes', + donePrefix: 'Managed Notes', + match: { + onlyOf: ['search_notes', 'view_note', 'write_note', 'replace_note_content'] + } + }, + { + id: 'remember_and_write_note', + order: 1000, + iconKey: 'note', + pendingPrefix: 'Remembering and Writing Note', + donePrefix: 'Remembered and Wrote Note', + match: { + onlyOf: ['write_memory', 'write_note'] + } + } +]; + +function normalizeToolName(name: string): string { + return name.trim().toLowerCase(); +} + +function hasAll(set: Set, items: string[]): boolean { + return items.every((item) => set.has(normalizeToolName(item))); +} + +function hasAny(set: Set, items: string[]): boolean { + return items.some((item) => set.has(normalizeToolName(item))); +} + +function hasNone(set: Set, items: string[]): boolean { + return items.every((item) => !set.has(normalizeToolName(item))); +} + +function hasOnly(set: Set, items: string[]): boolean { + if (items.length === 0) { + return set.size === 0; + } + + const allowed = new Set(items.map((item) => normalizeToolName(item))); + for (const value of set) { + if (!allowed.has(value)) { + return false; + } + } + + return true; +} + +function matchesCombinationRule(rule: ToolCombinationRule, set: Set): boolean { + const { allOf, anyOf, noneOf, onlyOf } = rule.match; + + if (allOf && allOf.length > 0 && !hasAll(set, allOf)) { + return false; + } + + if (anyOf && anyOf.length > 0 && !hasAny(set, anyOf)) { + return false; + } + + if (noneOf && noneOf.length > 0 && !hasNone(set, noneOf)) { + return false; + } + + if (onlyOf && !hasOnly(set, onlyOf)) { + return false; + } + + return true; +} + +export function getToolCombinationSummary(names: string[], pending: boolean): ToolCombination | null { + const set = new Set(names.map((name) => normalizeToolName(name))); + const orderedRules = [...TOOL_COMBINATION_RULES].sort((a, b) => a.order - b.order); + + for (const rule of orderedRules) { + if (!matchesCombinationRule(rule, set)) { + continue; + } + + return { + iconKey: rule.iconKey, + prefix: pending ? rule.pendingPrefix : rule.donePrefix, + showDetailList: rule.showDetailList ?? false, + matchedRuleId: rule.id + }; + } + + return null; +}