From 32beb244f5c2ca91c583be15d4671643b54ba238 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 15 Jul 2026 20:31:45 +0200 Subject: [PATCH] ui: Agentic Content UX improvements (#25450) * feat: Add shimmer text animation for processing state indicators * feat: Redesign CollapsibleContentBlock component with improved UX * feat: Add conditional setting display support with dependsOn field * feat: Add showAgenticTurnStats setting for per-turn statistics * feat: Update ChatMessageAgenticContent with improved UI and new features * feat: Enhance file read tool UI/UX * feat: Refine styling of collapsible content and code preview blocks * feat: add terminal variant to CollapsibleContentBlock * feat: add built-in tools UI registry * feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock * refactor: simplify ChatMessageAgenticContent to use extracted blocks * fix: correct markdown content block margin spacing * fix: reorganize SettingsChatFields layout and reset button positioning * fix: use direct map access in agentic store session methods * refactor: remove reasoning preview/throttle system from CollapsibleContentBlock * feat: add auto-scroll to reasoning block and remove showThoughtInProgress * feat: add ChatMessageToolCallDateTime component and support for new tool types * feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver * feat: show MCP server favicon for tools without a built-in icon * feat: add search-results parsing utilities and tests * feat: add ChatMessageToolCallSearchResults component * feat: integrate search results rendering into ChatMessageAgenticContent * feat: display tool call input alongside output in ChatMessageToolCallBlock * style: use muted foreground color in reasoning block content * chore: Format * feat: Refine reasoning block layout and make pending thoughts display configurable * feat: Stream tool call code blocks with auto-scroll and handle partial JSON * feat: add streaming permission gate infrastructure * feat: wire permission gate into the agentic loop * fix: bail out on abort and skip already-approved tool calls * fix: clear partial tool calls on abort and savePartialResponse * test: cover partial tool call cleanup end-to-end * refactor: Remove streaming permission gate logic * fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks * refactor: Chat Message Assistant componentization * fix: Show health metadata for disabled MCP servers and promote connections on enable * fix: Inherit global enabled state for missing MCP per-chat overrides * refactor: Cleanup * refactor: Split ChatMessageToolCallBlock into dedicated components * feat: Add live streaming and auto-scroll for tool execution output * feat: Add line numbers and change markers to file edit diffs * chore: Formatting * feat: Add type definitions and utilities for recommended MCP servers * feat: Add recommended MCP servers configuration and storage key * feat: Add McpServerCardCompact component for recommended servers * feat: Add recommended servers section to Add New Server dialog * feat: Update McpServerForm to support authorization requirements * feat: Add select-none classes for text selection prevention * feat: Add recommended MCP server icon assets * refactor: Store dismissed MCP recommendations as a boolean flag * feat: Render tool results as JSON or Markdown based on detected content type * feat: UI improvement * feat: Render search block early and update heading to show execution state * fix: Prevent non-web-search tools from triggering the search UI block * refactor: Cleanup * refactor: Extract hardcoded icon size classes into shared constants * refactor: Extract hardcoded tool result separator into a shared constant * refactor: Tool Calls UI/logic * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup --- tools/ui/eslint.config.js | 7 + tools/ui/src/app.css | 27 ++ .../actions/ActionIconCopyToClipboard.svelte | 3 +- ...hatAttachmentsListItemThumbnailFile.svelte | 5 +- ...hatAttachmentsPreviewCurrentItemPdf.svelte | 9 +- ...hatAttachmentsPreviewThumbnailStrip.svelte | 7 +- .../ChatFormActionAddButton.svelte | 3 +- .../ChatFormActionAddDropdown.svelte | 15 +- .../ChatFormActionAddMcpServersSubmenu.svelte | 9 +- .../ChatFormActionAddReasoningSubmenu.svelte | 9 +- .../ChatFormActionAddSheet.svelte | 47 +- .../ChatFormActionAddToolsSubmenu.svelte | 15 +- .../ChatFormActionRecord.svelte | 5 +- .../ChatFormActions/ChatFormActions.svelte | 5 +- .../ChatFormReasoningEffortSubmenu.svelte | 8 +- .../ChatMessage/ChatMessage.svelte | 1 - .../ChatMessageAssistant.svelte | 201 +-------- .../ChatMessageAssistantModel.svelte | 46 ++ .../ChatMessageAssistantProcessingInfo.svelte | 25 ++ .../ChatMessageAssistantRawOutput.svelte | 33 ++ .../ChatMessageAssistantStatistics.svelte | 40 ++ .../ChatMessageToolCallBlock.svelte | 66 +++ .../ChatMessageToolCallBlockDefault.svelte | 124 ++++++ .../ChatMessageToolCallBlockEditFile.svelte | 166 +++++++ ...essageToolCallBlockExecShellCommand.svelte | 293 ++++++++++++ ...tMessageToolCallBlockFileGlobSearch.svelte | 61 +++ ...ChatMessageToolCallBlockGetDatetime.svelte | 57 +++ .../ChatMessageToolCallBlockGrepSearch.svelte | 67 +++ .../ChatMessageToolCallBlockReadFile.svelte | 44 ++ ...atMessageToolCallBlockRunJavascript.svelte | 69 +++ ...atMessageToolCallBlockSearchResults.svelte | 167 +++++++ .../ChatMessageToolCallBlockWriteFile.svelte | 55 +++ .../ChatMessageToolCall/ToolCallBlock.svelte | 129 ++++++ .../ChatMessageToolCall/parsers/_shared.ts | 49 +++ .../ChatMessageToolCall/parsers/edit-file.ts | 71 +++ .../parsers/exec-shell-command.ts | 23 + .../parsers/file-glob-search.ts | 58 +++ .../parsers/grep-search.ts | 108 +++++ .../ChatMessageToolCall/parsers/read-file.ts | 52 +++ .../parsers/run-javascript.ts | 56 +++ .../ChatMessageToolCall/parsers/write-file.ts | 54 +++ .../ChatMessageActionCard.svelte | 3 +- .../ChatMessageAgenticContent.svelte | 254 +++-------- .../ChatMessageReasoningBlock.svelte | 151 +++++++ .../ChatScreenActionScrollDown.svelte | 3 +- .../ChatScreen/ChatScreenServerError.svelte | 5 +- tools/ui/src/lib/components/app/chat/index.ts | 4 + .../content/CollapsibleContentBlock.svelte | 148 +++---- .../content/CollapsibleTerminalBlock.svelte | 97 ++++ .../MarkdownContent/markdown-content.css | 10 +- .../app/content/MermaidPreviewControls.svelte | 9 +- .../app/content/SyntaxHighlightedCode.svelte | 109 +++-- .../src/lib/components/app/content/index.ts | 17 +- .../dialogs/DialogMcpResourcesBrowser.svelte | 11 +- .../app/dialogs/DialogMcpServerAddNew.svelte | 148 ++++++- .../dialogs/DialogModelNotAvailable.svelte | 3 +- .../components/app/forms/KeyValuePairs.svelte | 5 +- .../components/app/forms/SearchInput.svelte | 5 +- .../app/mcp/McpActiveServersAvatars.svelte | 5 +- .../app/mcp/McpResourcePreview.svelte | 3 +- .../McpResourcesBrowserHeader.svelte | 5 +- .../McpResourcesBrowserServerItem.svelte | 5 +- .../mcp/McpServerCard/McpServerCard.svelte | 5 +- .../McpServerCard/McpServerCardCompact.svelte | 44 ++ .../components/app/mcp/McpServerForm.svelte | 46 +- tools/ui/src/lib/components/app/mcp/index.ts | 11 + .../app/misc/HorizontalScrollCarousel.svelte | 5 +- .../app/models/ModelsSelectorOption.svelte | 3 +- .../SidebarNavigationActions.svelte | 5 +- .../SidebarNavigationConversationItem.svelte | 3 +- .../app/server/ServerErrorSplash.svelte | 19 +- .../components/app/server/ServerStatus.svelte | 3 +- .../settings/SettingsChat/SettingsChat.svelte | 4 +- .../SettingsChat/SettingsChatFields.svelte | 387 ++++++++-------- .../SettingsChatImportExportSection.svelte | 3 +- .../SettingsChat/SettingsChatToolsTab.svelte | 7 +- .../SettingsChatDesktopSidebar.svelte | 7 +- .../settings/SettingsChatMobileHeader.svelte | 11 +- .../app/settings/SettingsMcpServers.svelte | 5 +- tools/ui/src/lib/constants/agentic.ts | 26 +- tools/ui/src/lib/constants/auto-scroll.ts | 14 + tools/ui/src/lib/constants/built-in-tools.ts | 59 +++ tools/ui/src/lib/constants/code.ts | 16 + tools/ui/src/lib/constants/css-classes.ts | 6 + tools/ui/src/lib/constants/formatters.ts | 27 -- tools/ui/src/lib/constants/index.ts | 2 + tools/ui/src/lib/constants/markdown.ts | 1 + tools/ui/src/lib/constants/mcp.ts | 6 + .../lib/constants/recommended-mcp-servers.ts | 38 ++ tools/ui/src/lib/constants/sandbox.ts | 4 +- tools/ui/src/lib/constants/settings-keys.ts | 1 + .../ui/src/lib/constants/settings-registry.ts | 17 +- tools/ui/src/lib/constants/storage.ts | 1 + tools/ui/src/lib/constants/ui.ts | 3 + tools/ui/src/lib/constants/url.ts | 3 + tools/ui/src/lib/enums/agentic.enums.ts | 18 + tools/ui/src/lib/enums/index.ts | 10 +- tools/ui/src/lib/enums/tools.enums.ts | 19 + tools/ui/src/lib/hooks/use-throttle.svelte.ts | 32 -- .../ui/src/lib/services/migration.service.ts | 2 +- tools/ui/src/lib/services/sandbox.service.ts | 6 +- tools/ui/src/lib/services/tools.service.ts | 90 ++++ tools/ui/src/lib/stores/agentic.svelte.ts | 110 ++++- tools/ui/src/lib/stores/chat.svelte.ts | 55 ++- .../ui/src/lib/stores/mcp-resources.svelte.ts | 4 +- tools/ui/src/lib/stores/mcp.svelte.ts | 140 +++--- tools/ui/src/lib/types/agentic.d.ts | 14 + tools/ui/src/lib/types/chat.d.ts | 5 + tools/ui/src/lib/types/index.ts | 1 + tools/ui/src/lib/types/mcp.d.ts | 20 + tools/ui/src/lib/types/settings.d.ts | 2 + tools/ui/src/lib/utils/agentic.ts | 185 +++++++- tools/ui/src/lib/utils/api-headers.ts | 9 +- tools/ui/src/lib/utils/api-key-validation.ts | 3 +- tools/ui/src/lib/utils/code.ts | 27 +- tools/ui/src/lib/utils/compute-line-diff.ts | 105 +++++ tools/ui/src/lib/utils/formatters.ts | 36 +- tools/ui/src/lib/utils/index.ts | 56 ++- tools/ui/src/lib/utils/model-names.ts | 4 +- .../src/lib/utils/parse-exec-shell-error.ts | 19 + .../src/lib/utils/parse-exec-shell-status.ts | 47 ++ .../src/lib/utils/parse-partial-json-args.ts | 83 ++++ tools/ui/src/lib/utils/search-results.ts | 229 ++++++++++ tools/ui/src/lib/utils/sse.ts | 71 +++ tools/ui/src/lib/utils/text.ts | 4 +- tools/ui/src/lib/utils/tool-call-meta.ts | 27 ++ tools/ui/src/lib/utils/url.ts | 42 +- tools/ui/src/routes/+layout.svelte | 19 +- tools/ui/static/recommended-mcp/context7.png | Bin 0 -> 1489 bytes tools/ui/static/recommended-mcp/exa.ico | Bin 0 -> 15154 bytes .../ui/static/recommended-mcp/github-dark.png | Bin 0 -> 584 bytes .../static/recommended-mcp/github-light.png | Bin 0 -> 958 bytes .../ui/static/recommended-mcp/huggingface.ico | Bin 0 -> 205556 bytes tools/ui/tests/unit/agentic-sections.test.ts | 23 + .../tests/unit/assistant-raw-output.test.ts | 77 ++++ .../tests/unit/classify-tool-result.test.ts | 130 ++++++ tools/ui/tests/unit/code.test.ts | 82 ++++ tools/ui/tests/unit/compute-line-diff.test.ts | 136 ++++++ .../tests/unit/mcp-override-fallback.test.ts | 143 ++++++ .../unit/parse-exec-shell-status.test.ts | 67 +++ .../unit/partial-tool-call-cleanup.test.ts | 95 ++++ .../tests/unit/search-results-fixture.test.ts | 44 ++ tools/ui/tests/unit/search-results.test.ts | 118 +++++ tools/ui/tests/unit/sse.test.ts | 77 ++++ tools/ui/tests/unit/tool-call-meta.test.ts | 30 ++ tools/ui/tests/unit/tool-calls.test.ts | 416 ++++++++++++++++++ 146 files changed, 5960 insertions(+), 1053 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte create mode 100644 tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte create mode 100644 tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte create mode 100644 tools/ui/src/lib/constants/built-in-tools.ts create mode 100644 tools/ui/src/lib/constants/recommended-mcp-servers.ts delete mode 100644 tools/ui/src/lib/hooks/use-throttle.svelte.ts create mode 100644 tools/ui/src/lib/utils/compute-line-diff.ts create mode 100644 tools/ui/src/lib/utils/parse-exec-shell-error.ts create mode 100644 tools/ui/src/lib/utils/parse-exec-shell-status.ts create mode 100644 tools/ui/src/lib/utils/parse-partial-json-args.ts create mode 100644 tools/ui/src/lib/utils/search-results.ts create mode 100644 tools/ui/src/lib/utils/sse.ts create mode 100644 tools/ui/src/lib/utils/tool-call-meta.ts create mode 100644 tools/ui/static/recommended-mcp/context7.png create mode 100644 tools/ui/static/recommended-mcp/exa.ico create mode 100644 tools/ui/static/recommended-mcp/github-dark.png create mode 100644 tools/ui/static/recommended-mcp/github-light.png create mode 100644 tools/ui/static/recommended-mcp/huggingface.ico create mode 100644 tools/ui/tests/unit/assistant-raw-output.test.ts create mode 100644 tools/ui/tests/unit/classify-tool-result.test.ts create mode 100644 tools/ui/tests/unit/code.test.ts create mode 100644 tools/ui/tests/unit/compute-line-diff.test.ts create mode 100644 tools/ui/tests/unit/mcp-override-fallback.test.ts create mode 100644 tools/ui/tests/unit/parse-exec-shell-status.test.ts create mode 100644 tools/ui/tests/unit/partial-tool-call-cleanup.test.ts create mode 100644 tools/ui/tests/unit/search-results-fixture.test.ts create mode 100644 tools/ui/tests/unit/search-results.test.ts create mode 100644 tools/ui/tests/unit/sse.test.ts create mode 100644 tools/ui/tests/unit/tool-call-meta.test.ts create mode 100644 tools/ui/tests/unit/tool-calls.test.ts diff --git a/tools/ui/eslint.config.js b/tools/ui/eslint.config.js index b90376b6c1..54679a05be 100644 --- a/tools/ui/eslint.config.js +++ b/tools/ui/eslint.config.js @@ -29,6 +29,13 @@ export default ts.config( // This app uses hash-based routing (#/) where resolve() from $app/paths does not apply 'svelte/no-navigation-without-resolve': 'off', + // Snippet bodies often ignore one or more of the parent's params + // (e.g. `{#snippet children(_meta, ctx)}` when only ctx is read). + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ], + // Enforce empty line at end of file 'eol-last': 'error' } diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css index 8c4056477d..f9b544bebc 100644 --- a/tools/ui/src/app.css +++ b/tools/ui/src/app.css @@ -193,6 +193,33 @@ -ms-overflow-style: none; scrollbar-width: none; } + + .shimmer-text { + background: linear-gradient( + 90deg, + var(--muted-foreground), + var(--foreground), + var(--muted-foreground) + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + font-weight: 500; + animation: shimmer 1s linear infinite; + } + + @keyframes shimmer { + to { + background-position: -200% 0; + } + } + + @media (prefers-reduced-motion: reduce) { + .shimmer-text { + animation: none; + } + } } .mermaidTooltip { diff --git a/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte index 999f0cba9e..9b7b370ad0 100644 --- a/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte +++ b/tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte @@ -1,4 +1,5 @@ + +{#if isRouter} + { + const status = modelsStore.getModelStatus(modelId); + + if (status !== ServerModelStatus.LOADED) { + pendingModel = modelId; + + try { + await modelsStore.loadModel(modelId); + } finally { + pendingModel = null; + } + } + + onRegenerate(modelName); + return true; + }} + /> +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte new file mode 100644 index 0000000000..356512ecb7 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte @@ -0,0 +1,25 @@ + + +
+
+ + {modelLoadingText ?? + processingState.getPromptProgressText() ?? + processingState.getProcessingMessage() ?? + 'Processing...'} + +
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte new file mode 100644 index 0000000000..30ce16be93 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte @@ -0,0 +1,33 @@ + + +
{rawOutputContent || ''}
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte new file mode 100644 index 0000000000..4cc4080c3b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte @@ -0,0 +1,40 @@ + + +{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms} + {@const agentic = message.timings.agentic} + +{:else if isLoading && showMessageStats} + {@const liveStats = processingState.getLiveProcessingStats()} + {@const genStats = processingState.getLiveGenerationStats()} + + {#if genStats} + + {/if} +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte new file mode 100644 index 0000000000..b1daedfc81 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -0,0 +1,66 @@ + + +{#if isSearchCall} + +{:else if section.toolName === BuiltInTool.GET_DATETIME} + +{:else if section.toolName === BuiltInTool.READ_FILE} + +{:else if section.toolName === BuiltInTool.EDIT_FILE} + +{:else if section.toolName === BuiltInTool.WRITE_FILE} + +{:else if section.toolName === BuiltInTool.EXEC_SHELL_COMMAND} + +{:else if section.toolName === BuiltInTool.FILE_GLOB_SEARCH} + +{:else if section.toolName === BuiltInTool.GREP_SEARCH} + +{:else if section.toolName === BuiltInTool.RUN_JAVASCRIPT} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte new file mode 100644 index 0000000000..acf2de12ac --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte @@ -0,0 +1,124 @@ + + + + {#snippet children(_meta, ctx)} + {#if ctx.isStreamingCall} +
+ Input + {#if ctx.isStreaming} + + {/if} +
+ {#if section.toolArgs} + + {:else if ctx.isStreaming} +
+ Receiving arguments... +
+ {:else} +
+ Response was truncated +
+ {/if} + {:else} + {@const showInput = Boolean(section.toolArgs)} + {#if showInput} +
+ Input +
+ + {/if} +
+ Output + {#if ctx.isPending} + + {/if} +
+ {#if ctx.isPending} +
+ Waiting for result... +
+ {:else if section.toolResult} + {#if outputKind === ToolResultKind.JSON} + + {:else if outputKind === ToolResultKind.MARKDOWN} + + {:else} +
+ {#each parsedLines as line, i (i)} +
+ {line.text} +
+ {#if line.image} + {line.image.name} + {/if} + {/each} +
+ {/if} + {:else} +
No output
+ {/if} + {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte new file mode 100644 index 0000000000..6f30060f54 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte @@ -0,0 +1,166 @@ + + + + {#snippet titleSnippet()} + Edit file + {editFileMeta?.filePath} + {#if editFileMeta?.errorMessage} + (failed) + {/if} + {/snippet} + + {#snippet children(meta, _ctx)} + {#if meta?.errorMessage} +
+ + {meta.errorMessage} +
+ {:else if meta && meta.edits.length > 0} + {#each editDiffs as diffLines, ei (ei)} +
+
+ Edit {ei + 1} of {meta.edits.length} +
+
+
+ {#each diffLines as line, li (li)} +
+ {line.oldLine ?? ''} + {prefixFor(line.kind)} + {line.newLine ?? ''} + {line.text || ' '} +
+ {/each} +
+
+
+ {/each} +
+ {#if meta.resultMessage} + {meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if} + {#if meta.editsApplied != null} + {meta.editsApplied} + {meta.editsApplied === 1 ? 'edit' : 'edits'} applied + {/if} +
+ {:else} +
No edits
+ {/if} + {/snippet} +
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte new file mode 100644 index 0000000000..5de801d39a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte @@ -0,0 +1,293 @@ + + +{#snippet execShellTitle()} + {#if highlightedCommandHtml} + {@html highlightedCommandHtml} + {:else} + {execShellMeta?.command} + {/if} +{/snippet} + + + {#snippet titleSnippet()} + {@render execShellTitle()} + {/snippet} + + {#snippet children(_meta, ctx)} + {#if ctx.isPending} +
+ + Running... +
+ {:else if execShellError} +
+ + {execShellError} +
+ {:else if section.toolResult} +
+ {#each outputLines as line, i (i)} +
{line.text}
+ {#if line.image} + {line.image.name} + {/if} + {/each} + + {#if isExitCodeFinalLine && execShellExitStatus} +
+ {#if execShellExitStatus.timedOut} + + timed out + · + exit {execShellExitStatus.code} + {:else if execShellExitStatus.code === 0} + + exit 0 + {:else} + + exit {execShellExitStatus.code} + {/if} +
+ {/if} +
+ {/if} + {/snippet} +
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte new file mode 100644 index 0000000000..ad082039ff --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte @@ -0,0 +1,61 @@ + + + + {#snippet titleSnippet()} + {#if fileGlobMeta} + {fileGlobMeta.include === '**' ? 'List files' : 'Search files'}  + {#if fileGlobMeta.include !== '**'} + {fileGlobMeta.include} + {/if} +  in  + {fileGlobMeta.path} + {/if} + {/snippet} + + {#snippet children(meta, ctx)} + {#if ctx.isPending} +
+ Searching... +
+ {:else if meta?.errorMessage} +
+ + {meta.errorMessage} +
+ {:else if meta && meta.matches.length > 0} +
+ {#each meta.matches as match, i (i)} +
{match}
+ {/each} +
+
+ Total matches: {meta.totalMatches ?? meta.matches.length} +
+ {:else} +
No matches
+
+ Total matches: {meta?.totalMatches ?? 0} +
+ {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte new file mode 100644 index 0000000000..e0c701deaa --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte @@ -0,0 +1,57 @@ + + +
+ + {#if showSpinner} + Current time + + {:else if dateMeta.errorMessage} + Current time  + - {dateMeta.errorMessage} + {:else if dateMeta.dateString} + Current time is  + {dateMeta.dateString} + {:else} + Current time + {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte new file mode 100644 index 0000000000..afb06fef71 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte @@ -0,0 +1,67 @@ + + + + {#snippet titleSnippet()} + {#if grepMeta} + Search for  + {grepMeta.pattern} +  in  + {grepMeta.path} + {/if} + {/snippet} + + {#snippet children(meta, ctx)} + {#if ctx.isPending} +
+ Searching... +
+ {:else if meta?.errorMessage} +
+ + {meta.errorMessage} +
+ {:else if meta && meta.matches.length > 0} +
+ {#each meta.matches as match, mi (mi)} +
+ {match.file} + {#if meta.showLineNumbers && match.line != null} + :{match.line} + {/if} + : + {match.content} +
+ {/each} +
+
+ Total matches: {meta.totalMatches ?? meta.matches.length} + {#if meta.showLineNumbers} +  (with line numbers) + {/if} +
+ {:else} +
No matches
+
+ Total matches: {meta?.totalMatches ?? 0} +
+ {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte new file mode 100644 index 0000000000..a99ff9ceed --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte @@ -0,0 +1,44 @@ + + + + {#snippet titleSnippet()} + Read file + {readFileMeta?.fileName} + {#if readFileMeta?.lineRange} +  (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end}) + {/if} + {/snippet} + + {#snippet children(_meta, _ctx)} + {#if section.toolResult} + + {:else} +
+ Waiting for file content... +
+ {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte new file mode 100644 index 0000000000..707d83d737 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte @@ -0,0 +1,69 @@ + + + + {#snippet children(meta, ctx)} + {#if ctx.isPending} +
Running...
+ {:else if meta?.errorMessage} +
+ + {meta.errorMessage} +
+
+ +
+ {:else if meta} + +
+ + Console + {#if meta.timeoutMs != null} + · timeout {meta.timeoutMs} ms + {/if} +
+ {#if section.toolResult} +
+ +
+ {:else} +
No output
+ {/if} + {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte new file mode 100644 index 0000000000..e4b4adf151 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte @@ -0,0 +1,167 @@ + + +{#snippet pill(result: SearchResult)} + {@const faviconUrl = faviconForUrl(result.url)} + {@const safeUrl = sanitizeExternalUrl(result.url)} + {@const showHoverCard = safeUrl !== null && hasDetails(result)} + {#if safeUrl} + + + {#if faviconUrl} + + {:else} + + {/if} + {result.title} + + {#if showHoverCard} + {@const publishDate = formatPublishDate(result.published)} + {@const host = hostFor(safeUrl)} + +
+ {result.title} + {#if publishDate || result.author} +
+ {#if publishDate} + {publishDate} + {/if} + {#if publishDate && result.author} + · + {/if} + {#if result.author} + {result.author} + {/if} +
+ {/if} + {#if result.highlights} +

+ {result.highlights} +

+ {/if} + {#if host} +
{host}
+ {/if} +
+
+ {/if} +
+ {/if} +{/snippet} + + + {#if results.length > 0} +
+ {#each results as result (result.url)} + {@render pill(result)} + {/each} +
+ {:else if showSpinner} +
+ + Searching... +
+ {:else} +
No results
+ {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte new file mode 100644 index 0000000000..eda0676623 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte @@ -0,0 +1,55 @@ + + + + {#snippet titleSnippet()} + Write file + {writeFileMeta?.filePath} + {#if writeFileMeta?.errorMessage} + (failed) + {/if} + {/snippet} + + {#snippet children(meta, ctx)} + {#if meta?.errorMessage} +
+ + {meta.errorMessage} +
+ {:else if meta} + +
+ {#if meta.resultMessage} + {meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if} + {#if meta.bytesWritten != null} + {meta.bytesWritten} + bytes + {/if} +
+ {/if} + {/snippet} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte new file mode 100644 index 0000000000..a17a74e161 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte @@ -0,0 +1,129 @@ + + + + {@render children(meta, { + isStreaming, + isPending, + isStreamingCall, + isCodeStreaming + })} + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts new file mode 100644 index 0000000000..6114f17b5b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts @@ -0,0 +1,49 @@ +// Helpers shared by the per-tool meta parsers under +// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`. +// Each tool needs the same first three steps (tool-name check, +// args-present check, JSON parse) - keeping them here lets each parser +// stay focused on its own format quirks. + +import { BuiltInTool } from '$lib/enums'; +import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args'; +import type { AgenticSection } from '$lib/utils/agentic'; + +/** + * Strict (final-state) JSON parser for a tool-args blob. Mirrors the + * behaviour the per-tool components used before extraction: an + * invalid JSON blob, a JSON array, or a JSON primitive all map to + * `null` so callers don't have to guard against surprise shapes. + */ +function parseFinalToolArgs(blob: string): Record | null { + try { + const parsed: unknown = JSON.parse(blob); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +} + +/** + * Parse a section's toolArgs against an expected tool name. Returns + * `null` when: + * - the section's toolName doesn't match (component isn't for this + * tool); + * - the section has no args yet (call hasn't started streaming); + * - or the args blob can't be parsed. + * + * Pass `{ partial: true }` for tools that need to render incrementally + * as each token lands (read_file, edit_file, write_file). + */ +export function parseToolArgs( + expected: BuiltInTool, + section: AgenticSection, + options: { partial?: boolean } = {} +): Record | null { + if (section.toolName !== expected || !section.toolArgs) return null; + return options.partial + ? parsePartialJsonArgs(section.toolArgs) + : parseFinalToolArgs(section.toolArgs); +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts new file mode 100644 index 0000000000..4bff25bb54 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts @@ -0,0 +1,71 @@ +// Meta parser for `edit_file` tool calls. Reads the file path and the +// array of edits from the streamed args (partial JSON for incremental +// rendering), plus the result blob for `result` / `edits_applied` / +// `error` fields. + +import { BuiltInTool } from '$lib/enums'; +import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants'; +import { tryParseToolResultObject, type AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type EditFileEdit = { + oldText: string; + newText: string; +}; + +export type EditFileMeta = { + fileName: string; + filePath: string; + edits: EditFileEdit[]; + resultMessage?: string; + editsApplied?: number; + errorMessage?: string; +}; + +export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null { + const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true }); + if (!args) return null; + + const rawPath = args.path ?? args.file_path ?? args.filePath; + if (typeof rawPath !== 'string' || !rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + + // Filter the streamed edits array strictly: each entry must be an + // object with a non-empty `old_text`. Edits without an old_text + // would diff against empty and render as a full re-write. + const rawEdits = Array.isArray(args.edits) ? args.edits : []; + const edits: EditFileEdit[] = []; + for (const e of rawEdits) { + if (!e || typeof e !== 'object' || Array.isArray(e)) continue; + const obj = e as Record; + const oldText = typeof obj.old_text === 'string' ? obj.old_text : ''; + if (!oldText) continue; + const newText = typeof obj.new_text === 'string' ? obj.new_text : ''; + edits.push({ oldText, newText }); + } + + const resultObj = tryParseToolResultObject(section.toolResult); + let resultMessage: string | undefined; + let editsApplied: number | undefined; + let errorMessage: string | undefined; + if (typeof resultObj?.error === 'string') { + errorMessage = resultObj.error; + } else if (resultObj) { + if (typeof resultObj.result === 'string') { + resultMessage = resultObj.result; + } + if (Number.isFinite(Number(resultObj.edits_applied))) { + editsApplied = Number(resultObj.edits_applied); + } + } + + return { + fileName, + filePath: rawPath, + edits, + resultMessage, + editsApplied, + errorMessage + }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts new file mode 100644 index 0000000000..e8adbd18b0 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts @@ -0,0 +1,23 @@ +// Meta parser for `exec_shell_command` tool calls. Surfaces the +// command text from args `command` / `cmd` / `shell_command` aliases. +// The exit-status and error parsing live in their own utilities +// (`parse-exec-shell-status.ts` / `parse-exec-shell-error.ts`) - this +// file only deals with what's strictly about *calling* the tool, since +// the error / exit status elide from call-section to result-section. + +import { BuiltInTool } from '$lib/enums'; +import type { AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type ExecShellCommandMeta = { + command: string; +}; + +export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null { + const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section); + if (!args) return null; + + const commandRaw = args.command ?? args.cmd ?? args.shell_command; + if (typeof commandRaw !== 'string' || !commandRaw) return null; + return { command: commandRaw }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts new file mode 100644 index 0000000000..1ad92b74cf --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts @@ -0,0 +1,58 @@ +// Meta parser for `file_glob_search` tool calls. Reads the path, +// include pattern, and optional exclude from the args (strict parsing) +// and the matches from the result blob. Like grep_search, the result +// parser keeps the original raw-text fallback for MCP servers that +// emit unparseable output. + +import { BuiltInTool } from '$lib/enums'; +import { splitSearchSummaryList, type AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type FileGlobSearchMeta = { + path: string; + include: string; + exclude?: string; + matches: string[]; + totalMatches?: number; + errorMessage?: string; +}; + +export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null { + const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section); + if (!args) return null; + + const path = typeof args.path === 'string' ? args.path : ''; + const include = typeof args.include === 'string' && args.include ? args.include : '**'; + const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined; + if (!path) return null; + + let matches: string[] = []; + let totalMatches: number | undefined; + let errorMessage: string | undefined; + + const toolResultString = section.toolResult; + if (toolResultString) { + try { + const parsed: unknown = JSON.parse(toolResultString); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record; + if (typeof obj.error === 'string') { + errorMessage = obj.error; + } else if (typeof obj.plain_text_response === 'string') { + const split = splitSearchSummaryList(obj.plain_text_response, (total) => { + totalMatches = total; + }); + matches = split.lines; + } + } + } catch { + // See grep-search.ts: same fallback used there. + const split = splitSearchSummaryList(toolResultString, (total) => { + totalMatches = total; + }); + matches = split.lines; + } + } + + return { path, include, exclude, matches, totalMatches, errorMessage }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts new file mode 100644 index 0000000000..0e606e193c --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts @@ -0,0 +1,108 @@ +// Meta parser for `grep_search` tool calls. Reads the path/pattern +// triplet from args (strict parsing - we wait for the args to +// complete) and the matches from the result blob. The result parser +// keeps the original "scan result as raw text on JSON.parse failure" +// fallback so MCP servers that return unparseable output still get +// surfaced. + +import { BuiltInTool } from '$lib/enums'; +import { splitSearchSummaryList, type AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type GrepSearchMatch = { + file: string; + line?: number; + content: string; +}; + +export type GrepSearchMeta = { + path: string; + pattern: string; + include: string; + exclude?: string; + showLineNumbers: boolean; + matches: GrepSearchMatch[]; + totalMatches?: number; + errorMessage?: string; +}; + +export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null { + const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section); + if (!args) return null; + + const path = typeof args.path === 'string' ? args.path : ''; + const pattern = typeof args.pattern === 'string' ? args.pattern : ''; + if (!path || !pattern) return null; + + const include = typeof args.include === 'string' && args.include ? args.include : '**'; + const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined; + const showLineNumbers = args.return_line_numbers === true; + + let matches: GrepSearchMatch[] = []; + let totalMatches: number | undefined; + let errorMessage: string | undefined; + + const toolResultString = section.toolResult; + if (toolResultString) { + try { + const parsed: unknown = JSON.parse(toolResultString); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record; + if (typeof obj.error === 'string') { + errorMessage = obj.error; + } else if (typeof obj.plain_text_response === 'string') { + const split = splitSearchSummaryList(obj.plain_text_response, (total) => { + totalMatches = total; + }); + matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers)); + } + } + } catch { + // Result wasn't JSON: keep behaviour for MCP servers that + // emit raw text and treat each line as a `:` + // (or `::`) match. + const split = splitSearchSummaryList(toolResultString, (total) => { + totalMatches = total; + }); + matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers)); + } + } + + return { + path, + pattern, + include, + exclude, + showLineNumbers, + matches, + totalMatches, + errorMessage + }; +} + +function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch { + // Server output: + // : when return_line_numbers=false + // :: when return_line_numbers=true + const firstColon = line.indexOf(':'); + if (firstColon === -1) { + return { file: line, content: '' }; + } + const file = line.slice(0, firstColon); + const tail = line.slice(firstColon + 1); + + if (!showLineNumbers) { + return { file, content: tail }; + } + + const secondColon = tail.indexOf(':'); + if (secondColon === -1) { + return { file, content: tail }; + } + const lineNum = parseInt(tail.slice(0, secondColon), 10); + return { + file, + line: Number.isFinite(lineNum) ? lineNum : undefined, + content: tail.slice(secondColon + 1) + }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts new file mode 100644 index 0000000000..d37dcf5010 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts @@ -0,0 +1,52 @@ +// Meta parser for `read_file` tool calls. Reads the file path and an +// optional line range (either `start_line`+`end_line` or +// `start_line`+`line_count`). Args are parsed partially so a header +// can render incrementally as the file path streams in. + +import { BuiltInTool } from '$lib/enums'; +import { + DEFAULT_LANGUAGE, + FILE_PATH_SEPARATOR_REGEX, + TEXT_LANGUAGE_PREFIX_REGEX +} from '$lib/constants'; +import { getFileTypeByExtension, type AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type ReadFileMeta = { + fileName: string; + lineRange: { start: number; end: number } | null; + language: string; +}; + +export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null { + const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true }); + if (!args) return null; + + const rawPath = args.path ?? args.file_path ?? args.filePath; + if (typeof rawPath !== 'string' || !rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + + // Models emit range arguments under several aliases. Accept all to + // stay forgiving across prompt variations. + const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line; + const endRaw = args.end_line ?? args.line_end ?? args.endLine ?? args.to_line; + const countRaw = args.line_count ?? args.count ?? args.num_lines; + + let lineRange: { start: number; end: number } | null = null; + const sNum = Number(startRaw); + const eNum = Number(endRaw); + if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) { + lineRange = { start: sNum, end: eNum }; + } else if (startRaw != null && countRaw != null) { + const cNum = Number(countRaw); + if (Number.isFinite(sNum) && Number.isFinite(cNum)) { + lineRange = { start: sNum, end: sNum + cNum - 1 }; + } + } + + const fileType = getFileTypeByExtension(fileName); + const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE; + + return { fileName, lineRange, language }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts new file mode 100644 index 0000000000..9bcba8f03c --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts @@ -0,0 +1,56 @@ +// Meta parser for `run_javascript` tool calls. Reads the JS code and +// optional timeout from args (strict parsing) and surfaces any error +// from the result blob. SandboxService.formatReply emits a JSON object +// containing an `error` field on failure, but a partial/non-JSON +// failure renders as a flat line beginning with `Error:`. Both shapes +// are handled. + +import { BuiltInTool } from '$lib/enums'; +import type { AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type RunJavascriptMeta = { + code: string; + timeoutMs?: number; + errorMessage?: string; +}; + +export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null { + const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section); + if (!args) return null; + + const code = typeof args.code === 'string' ? args.code : ''; + if (!code) return null; + + const timeoutRaw = Number(args.timeout_ms); + const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined; + + let errorMessage: string | undefined; + const toolResultString = section.toolResult; + if (toolResultString) { + // Branches matter here: a JSON object can carry `error`, but a + // JSON array always represents successful output (sandbox returns + // the array of values). Only when the result isn't a JSON object + // do we scan raw lines for the `Error:` prefix. + let parsedObject: Record | null = null; + try { + const parsed: unknown = JSON.parse(toolResultString); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedObject = parsed as Record; + } + } catch { + parsedObject = null; + } + if (typeof parsedObject?.error === 'string') { + errorMessage = parsedObject.error; + } else if (!parsedObject) { + const errorLine = toolResultString + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('Error:')); + if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim(); + } + } + + return { code, timeoutMs, errorMessage }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts new file mode 100644 index 0000000000..95edc3d95e --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts @@ -0,0 +1,54 @@ +// Meta parser for `write_file` tool calls. Reads the path/content from +// the streamed args (partial JSON so we can render before the call +// finishes) and surfaces `bytes`, `result`, and `error` from the +// result blob. + +import { BuiltInTool } from '$lib/enums'; +import { + DEFAULT_LANGUAGE, + FILE_PATH_SEPARATOR_REGEX, + TEXT_LANGUAGE_PREFIX_REGEX +} from '$lib/constants'; +import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils'; +import { parseToolArgs } from './_shared'; + +export type WriteFileMeta = { + fileName: string; + filePath: string; + language: string; + content: string; + bytesWritten?: number; + resultMessage?: string; + errorMessage?: string; +}; + +export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null { + const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true }); + if (!args) return null; + + // Tool contracts drifted over time: some models emit `path`, + // others `file_path` / `filePath`. Accept all three. + const rawPath = args.path ?? args.file_path ?? args.filePath; + if (typeof rawPath !== 'string' || !rawPath) return null; + + const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath; + const content = typeof args.content === 'string' ? args.content : ''; + const language = + getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE; + + const resultObj = tryParseToolResultObject(section.toolResult); + const bytesWritten = + resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined; + const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined; + const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined; + + return { + fileName, + filePath: rawPath, + language, + content, + bytesWritten, + resultMessage, + errorMessage + }; +} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte index 254031979d..17d8e21d7b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte @@ -1,4 +1,5 @@ -{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)} +{#snippet renderSection(section: AgenticSection, index: number)} {#if section.type === AgenticSectionType.TEXT}
- {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} - {@const streamingIcon = isStreaming ? Loader2 : Loader2} - {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} - - toggleExpanded(index, section)} - > -
-
- Arguments: - - {#if isStreaming} - - {/if} -
- {#if section.toolArgs} - - {:else if isStreaming} -
- Receiving arguments... -
- {:else} -
- Response was truncated -
- {/if} -
-
- {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING} - {@const toolIcon = isPending ? Loader2 : Wrench} - {@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} - - + {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING} + toggleExpanded(index, section)} - > - {#if section.toolArgs && section.toolArgs !== '{}'} -
-
Arguments:
- - -
- {/if} - -
-
- Result: - - {#if isPending} - - {/if} -
- {#if isPending} -
- Waiting for result... -
- {:else if section.toolResult} -
- {#each section.parsedLines as line, i (i)} -
- {line.text} -
- {#if line.image} - {line.image.name} - {/if} - {/each} -
- {:else} -
No output
- {/if} -
-
- {:else if section.type === AgenticSectionType.REASONING} - {@const reasoningSubtitle = section.wasInterrupted - ? hasReasoningError - ? 'Error' - : 'Cancelled' - : isStreaming - ? '' - : undefined} - - toggleExpanded(index, section)} - > -
- {#if renderThinkingAsMarkdown} - - {:else} -
- {section.content} -
- {/if} -
-
- {:else if section.type === AgenticSectionType.REASONING_PENDING} - {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} - {@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'} - - toggleExpanded(index, section)} - > -
- {#if renderThinkingAsMarkdown} - - {:else} -
- {section.content} -
- {/if} -
-
+ /> {/if} {/snippet} -
+
{#if turnGroups.length > 1} {#each turnGroups as turn, turnIndex (turnIndex)} {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]} -
+
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])} {@render renderSection(section, turn.flatIndices[sIdx])} {/each} - {#if turnStats && showMessageStats} -
+ {#if turnStats && showAgenticTurnStats} +
{/each} {:else} - {#each sectionsParsed as section, index (index)} + {#each sections as section, index (index)} {@render renderSection(section, index)} {/each} {/if} @@ -404,7 +267,6 @@ flex-direction: column; width: 100%; max-width: 48rem; - gap: 1rem; } .agentic-content > :global(*), diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte new file mode 100644 index 0000000000..833cae5db5 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte @@ -0,0 +1,151 @@ + + + +
+ {#if renderThinkingAsMarkdown} + + {:else} +
+ {section.content} +
+ {/if} +
+
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte index c470ac7296..dca24afd44 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte @@ -1,4 +1,5 @@ @@ -76,59 +45,54 @@ open = value; onToggle?.(); }} - class="{className} my-0!" + class={cn('group/collapsible', 'my-0!', className)} > - - -
-
- {#if IconComponent} - - {/if} + +
+ {#if iconUrl} + + {:else if IconComponent} + + {/if} - {title} - - {#if subtitle} - {subtitle} - {/if} -
- - {#if displayedPreview && !showThoughtInProgress} -
-
- {displayedPreview} -
- {#if displayedOverflow > 0} - {displayedOverflow}+ chars - {/if} -
+ + {#if titleSnippet} + {@render titleSnippet()} + {:else} + {title} {/if} -
+ -
- + {#if subtitle} + {subtitle} + {/if} +
- Toggle content -
-
+ - -
+ Toggle content + + + +
+
{@render children()}
- - +
+
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte new file mode 100644 index 0000000000..4370aea42a --- /dev/null +++ b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte @@ -0,0 +1,97 @@ + + + { + open = value; + onToggle?.(); + }} + class={cn('group/collapsible', 'overflow-hidden rounded-md', className)} + style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);" +> + +
+ {#if iconUrl} + + {:else if IconComponent} + + {/if} + + + {#if titleSnippet} + {@render titleSnippet()} + {:else} + {title} + {/if} + + + {#if subtitle} + {subtitle} + {/if} +
+ + + + Toggle content +
+ + +
+ {@render children()} +
+
+
diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index b0e04ca620..41813f4fda 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -19,8 +19,16 @@ line-height: 1.75; } +.markdown-content :global(.markdown-block:first-child p:first-child) { + margin-block-start: 0; +} + +.markdown-content :global(.markdown-block:last-child p:last-child) { + margin-block-end: 0; +} + .markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) { - margin-top: 0; + margin-top: 0.5rem; } /* Headers with consistent spacing */ diff --git a/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte b/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte index bb3185f40e..39540e7a8c 100644 --- a/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte +++ b/tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte @@ -1,4 +1,5 @@
- +
{@html highlightedHtml}
diff --git a/tools/ui/src/lib/components/app/content/index.ts b/tools/ui/src/lib/components/app/content/index.ts index 5d2884bb21..5cfdd1b9c1 100644 --- a/tools/ui/src/lib/components/app/content/index.ts +++ b/tools/ui/src/lib/components/app/content/index.ts @@ -68,7 +68,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte * ```svelte * @@ -78,6 +77,22 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte */ export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte'; +/** + * **CollapsibleTerminalBlock** - Expandable content card with a terminal-style frame + * + * Same shape as CollapsibleContentBlock, but with a `code-background` + * fill, subtle border, and tightened padding suited for shell command + * output and similar dense / monospace content. + * + * @example + * ```svelte + * + *
{output}
+ *
+ * ``` + */ +export { default as CollapsibleTerminalBlock } from './CollapsibleTerminalBlock.svelte'; + /** * **MermaidPreview** - Interactive Mermaid diagram viewer * diff --git a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte index eb162a5572..f741b544bb 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte @@ -1,4 +1,5 @@ - + - Add New Server + Add New MCP Server + {#if recommendationsToShow.length > 0} +
+
+

Recommended Servers

+ +
+ +
+ {#each recommendationsToShow as recommendation (recommendation.id)} + handleRecommendationClick(recommendation.id)} + selected={selectedRecommendationId === recommendation.id} + dimmed={hasSelection && selectedRecommendationId !== recommendation.id} + /> + {/each} +
+
+ {/if} +
(newServerUseProxy = v)} urlError={newServerUrl ? newServerUrlError : null} id="new-server" + bind:wantsAuthorization={newServerWantsAuthorization} + required={authRequired} />
diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte index a6c20291fa..89d23cd4b2 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte @@ -1,4 +1,5 @@ + + +
+ {#if activeIconUrl} + + {/if} + +

{server.name}

+
+ +

{server.description}

+
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte index 7f05d5fef3..a7472add31 100644 --- a/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte +++ b/tools/ui/src/lib/components/app/mcp/McpServerForm.svelte @@ -5,9 +5,14 @@ import type { KeyValuePair } from '$lib/types'; import { parseHeadersToArray, serializeHeaders } from '$lib/utils'; import { UrlProtocol } from '$lib/enums'; - import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants'; + import { + AUTHORIZATION_HEADER, + BEARER_PREFIX, + CLI_FLAGS, + MCP_SERVER_URL_PLACEHOLDER, + REDACTED_HEADERS + } from '$lib/constants'; import { mcpStore } from '$lib/stores/mcp.svelte'; - import { CLI_FLAGS } from '$lib/constants'; interface Props { url: string; @@ -18,6 +23,22 @@ onUseProxyChange?: (useProxy: boolean) => void; urlError?: string | null; id?: string; + /** + * "Wants Authorization" is the user's *intent* to add a Bearer token + * (separate from `hasAuthorization` which reflects what's already in + * the headers). Bindable so a parent - e.g. the recommendation cards + * on the "Add New Server" dialog - can flip the switch on when the + * picked server ships a `needsAuthorization: true` flag. + */ + wantsAuthorization?: boolean; + /** + * Marks the "Authorization" field as required. Locks the toggle so the + * user can't dismiss it, and visually marks the field with a red + * asterisk. The parent is expected to gate its submit affordance on + * the bearer token actually being filled. Used by the "Add New Server" + * dialog for recommendations whose `needsAuthorization` flag is true. + */ + required?: boolean; } let { @@ -28,7 +49,9 @@ onHeadersChange, onUseProxyChange, urlError = null, - id = 'server' + id = 'server', + wantsAuthorization = $bindable(false), + required = false }: Props = $props(); let isWebSocket = $derived( @@ -38,14 +61,11 @@ let headerPairs = $derived(parseHeadersToArray(headers)); - const AUTHORIZATION_HEADER = 'Authorization'; - const BEARER_PREFIX = 'Bearer '; - // Heuristic: this dedicated UI only owns Authorization headers that already // carry a Bearer scheme. Anything else (e.g. Basic, raw tokens) stays in the // KV section so the user can still edit those values verbatim. const matchesAuthorizationKey = (key: string): boolean => - key.trim().toLowerCase() === AUTHORIZATION_HEADER.toLowerCase(); + REDACTED_HEADERS.has(key.trim().toLowerCase()); const isBearerScheme = (value: string): boolean => value.trim().toLowerCase().startsWith(BEARER_PREFIX.toLowerCase()); @@ -55,8 +75,6 @@ let hasAuthorization = $derived(headerPairs.some(ownedByBearerUi)); - let wantsAuthorization = $state(false); - let showAuthorization = $derived(hasAuthorization || wantsAuthorization); let urlInput: HTMLInputElement | null = $state(null); @@ -119,7 +137,7 @@
-
-