]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Agentic Content UX improvements (#25450)
authorAleksander Grygier <redacted>
Wed, 15 Jul 2026 18:31:45 +0000 (20:31 +0200)
committerGitHub <redacted>
Wed, 15 Jul 2026 18:31:45 +0000 (20:31 +0200)
* 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

146 files changed:
tools/ui/eslint.config.js
tools/ui/src/app.css
tools/ui/src/lib/components/app/actions/ActionIconCopyToClipboard.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsListItem/ChatAttachmentsListItemThumbnailFile.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewCurrentItem/ChatAttachmentsPreviewCurrentItemPdf.svelte
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddButton.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockDefault.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetDatetime.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockReadFile.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockRunJavascript.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockSearchResults.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageActions/ChatMessageActionCard/ChatMessageActionCard.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageReasoningBlock.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenActionScrollDown.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenServerError.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
tools/ui/src/lib/components/app/content/MermaidPreviewControls.svelte
tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
tools/ui/src/lib/components/app/content/index.ts
tools/ui/src/lib/components/app/dialogs/DialogMcpResourcesBrowser.svelte
tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
tools/ui/src/lib/components/app/dialogs/DialogModelNotAvailable.svelte
tools/ui/src/lib/components/app/forms/KeyValuePairs.svelte
tools/ui/src/lib/components/app/forms/SearchInput.svelte
tools/ui/src/lib/components/app/mcp/McpActiveServersAvatars.svelte
tools/ui/src/lib/components/app/mcp/McpResourcePreview.svelte
tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserHeader.svelte
tools/ui/src/lib/components/app/mcp/McpResourcesBrowser/McpResourcesBrowserServerItem.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCard.svelte
tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/mcp/McpServerForm.svelte
tools/ui/src/lib/components/app/mcp/index.ts
tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte
tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
tools/ui/src/lib/components/app/server/ServerErrorSplash.svelte
tools/ui/src/lib/components/app/server/ServerStatus.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatImportExportSection.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
tools/ui/src/lib/components/app/settings/SettingsChatDesktopSidebar.svelte
tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte
tools/ui/src/lib/components/app/settings/SettingsMcpServers.svelte
tools/ui/src/lib/constants/agentic.ts
tools/ui/src/lib/constants/auto-scroll.ts
tools/ui/src/lib/constants/built-in-tools.ts [new file with mode: 0644]
tools/ui/src/lib/constants/code.ts
tools/ui/src/lib/constants/css-classes.ts
tools/ui/src/lib/constants/formatters.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/markdown.ts
tools/ui/src/lib/constants/mcp.ts
tools/ui/src/lib/constants/recommended-mcp-servers.ts [new file with mode: 0644]
tools/ui/src/lib/constants/sandbox.ts
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/constants/storage.ts
tools/ui/src/lib/constants/ui.ts
tools/ui/src/lib/constants/url.ts
tools/ui/src/lib/enums/agentic.enums.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/tools.enums.ts
tools/ui/src/lib/hooks/use-throttle.svelte.ts [deleted file]
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/services/sandbox.service.ts
tools/ui/src/lib/services/tools.service.ts
tools/ui/src/lib/stores/agentic.svelte.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/mcp-resources.svelte.ts
tools/ui/src/lib/stores/mcp.svelte.ts
tools/ui/src/lib/types/agentic.d.ts
tools/ui/src/lib/types/chat.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/mcp.d.ts
tools/ui/src/lib/types/settings.d.ts
tools/ui/src/lib/utils/agentic.ts
tools/ui/src/lib/utils/api-headers.ts
tools/ui/src/lib/utils/api-key-validation.ts
tools/ui/src/lib/utils/code.ts
tools/ui/src/lib/utils/compute-line-diff.ts [new file with mode: 0644]
tools/ui/src/lib/utils/formatters.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/model-names.ts
tools/ui/src/lib/utils/parse-exec-shell-error.ts [new file with mode: 0644]
tools/ui/src/lib/utils/parse-exec-shell-status.ts [new file with mode: 0644]
tools/ui/src/lib/utils/parse-partial-json-args.ts [new file with mode: 0644]
tools/ui/src/lib/utils/search-results.ts [new file with mode: 0644]
tools/ui/src/lib/utils/sse.ts [new file with mode: 0644]
tools/ui/src/lib/utils/text.ts
tools/ui/src/lib/utils/tool-call-meta.ts [new file with mode: 0644]
tools/ui/src/lib/utils/url.ts
tools/ui/src/routes/+layout.svelte
tools/ui/static/recommended-mcp/context7.png [new file with mode: 0644]
tools/ui/static/recommended-mcp/exa.ico [new file with mode: 0644]
tools/ui/static/recommended-mcp/github-dark.png [new file with mode: 0644]
tools/ui/static/recommended-mcp/github-light.png [new file with mode: 0644]
tools/ui/static/recommended-mcp/huggingface.ico [new file with mode: 0644]
tools/ui/tests/unit/agentic-sections.test.ts
tools/ui/tests/unit/assistant-raw-output.test.ts [new file with mode: 0644]
tools/ui/tests/unit/classify-tool-result.test.ts [new file with mode: 0644]
tools/ui/tests/unit/code.test.ts [new file with mode: 0644]
tools/ui/tests/unit/compute-line-diff.test.ts [new file with mode: 0644]
tools/ui/tests/unit/mcp-override-fallback.test.ts [new file with mode: 0644]
tools/ui/tests/unit/parse-exec-shell-status.test.ts [new file with mode: 0644]
tools/ui/tests/unit/partial-tool-call-cleanup.test.ts [new file with mode: 0644]
tools/ui/tests/unit/search-results-fixture.test.ts [new file with mode: 0644]
tools/ui/tests/unit/search-results.test.ts [new file with mode: 0644]
tools/ui/tests/unit/sse.test.ts [new file with mode: 0644]
tools/ui/tests/unit/tool-call-meta.test.ts [new file with mode: 0644]
tools/ui/tests/unit/tool-calls.test.ts [new file with mode: 0644]

index b90376b6c156163724d81ec433172ea8789f0a71..54679a05be17a9ed9bbef01a2b4422f3650c0310 100644 (file)
@@ -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'
                }
index 8c4056477dbf3fa070e3a09102d2f226e738469a..f9b544bebc5637b3c3d40cb036d17c9715f1e45c 100644 (file)
                -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 {
index 999f0cba9e785067df3ecc0c12b5f184f936592a..9b7b370ad0859eab35d9d4289f7194ecafeca4ca 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Copy } from '@lucide/svelte';
        import { copyToClipboard } from '$lib/utils';
        import ActionIcon from './ActionIcon.svelte';
@@ -11,7 +12,7 @@
 <ActionIcon
        icon={Copy}
        tooltip={ariaLabel}
-       iconSize="h-4 w-4"
+       iconSize={ICON_CLASS_DEFAULT}
        disabled={!canCopy}
        onclick={() => canCopy && copyToClipboard(text)}
 />
index 2e824ebd4164703e1b8d6c45da08b88954c13708..63d0a715a1f3d754cc5828022c40a07e1faf3de9 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { X, Music, Video } from '@lucide/svelte';
        import {
                formatFileSize,
                class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
        >
                {#if isAudio}
-                       <Music class="h-4 w-4 text-white/70" />
+                       <Music class="{ICON_CLASS_DEFAULT} text-white/70" />
                {:else if isVideo}
-                       <Video class="h-4 w-4 text-white/70" />
+                       <Video class="{ICON_CLASS_DEFAULT} text-white/70" />
                {:else}
                        {fileTypeLabel}
                {/if}
index 750532a6266019719ba8e74c0fd166d0be95233c..7c7cf5120e1adf472b8e268a51ef31f12740d951 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import type { ChatAttachmentDisplayItem } from '$lib/types';
        import { FileText, Eye, Info } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
@@ -88,7 +89,7 @@
                onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
                disabled={pdfImagesLoading}
        >
-               <FileText class="mr-1 h-4 w-4" />
+               <FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
                Text
        </Button>
 
        >
                {#if pdfImagesLoading}
                        <div
-                               class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+                               class="mr-1 {ICON_CLASS_DEFAULT} animate-spin rounded-full border-2 border-current border-t-transparent"
                        ></div>
                {:else}
-                       <Eye class="mr-1 h-4 w-4" />
+                       <Eye class="mr-1 {ICON_CLASS_DEFAULT}" />
                {/if}
                Pages
        </Button>
 
 {#if !hasVisionModality && activeModelId && currentItem}
        <Alert.Root class="mb-4 max-w-4xl">
-               <Info class="h-4 w-4" />
+               <Info class={ICON_CLASS_DEFAULT} />
                <Alert.Title>Preview only</Alert.Title>
                <Alert.Description>
                        <span class="inline-flex">
index 3f19ee39c108bd91074fbc9c76e5489281f2873e..8a85df7d0c0e511df7a1f601a0a48e271749b7c0 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Music, Video, FileText } from '@lucide/svelte';
        import { HorizontalScrollCarousel } from '$lib/components/app/misc';
 
                                                        class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
                                                >
                                                        {#if item.isAudio}
-                                                               <Music class="h-4 w-4 text-white/70" />
+                                                               <Music class="{ICON_CLASS_DEFAULT} text-white/70" />
                                                        {:else if item.isVideo}
-                                                               <Video class="h-4 w-4 text-white/70" />
+                                                               <Video class="{ICON_CLASS_DEFAULT} text-white/70" />
                                                        {:else}
-                                                               <FileText class="h-4 w-4 text-white/70" />
+                                                               <FileText class="{ICON_CLASS_DEFAULT} text-white/70" />
                                                        {/if}
 
                                                        <span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
index ad2a0def9ec24e22b695846ef86fa0f035fc9805..b281ba7e5483bd846b5bdb6cec5ccfda6b637a99 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Plus } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -23,7 +24,7 @@
                >
                        <span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
 
-                       <Plus class="h-4 w-4" />
+                       <Plus class={ICON_CLASS_DEFAULT} />
                </Button>
        </Tooltip.Trigger>
 
index 433a3662df2ea60f5b0cde1a289bcb5e958d2648..699605fd82fb9d7402df9e55414c76cfbc4a3f45 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -83,7 +84,7 @@
                                        >
                                                <span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
 
-                                               <Plus class="h-4 w-4" />
+                                               <Plus class={ICON_CLASS_DEFAULT} />
                                        </DropdownMenu.Trigger>
                                {/snippet}
                        </Tooltip.Trigger>
 
                        <DropdownMenu.Sub>
                                <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
-                                       <File class="h-4 w-4" />
+                                       <File class={ICON_CLASS_DEFAULT} />
 
                                        <span>Add files</span>
                                </DropdownMenu.SubTrigger>
                                                                class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
                                                                onclick={() => attachmentMenu.callbacks[item.action]()}
                                                        >
-                                                               <item.icon class="h-4 w-4" />
+                                                               <item.icon class={ICON_CLASS_DEFAULT} />
 
                                                                <span>{item.label}</span>
                                                        </DropdownMenu.Item>
                                                                                                class="{item.class ?? ''} flex items-center gap-2"
                                                                                                disabled
                                                                                        >
-                                                                                               <item.icon class="h-4 w-4" />
+                                                                                               <item.icon class={ICON_CLASS_DEFAULT} />
 
                                                                                                <span>{item.label}</span>
                                                                                        </DropdownMenu.Item>
                                class="flex cursor-pointer items-center gap-2"
                                onclick={onSystemPromptClick}
                        >
-                               <MessageSquare class="h-4 w-4" />
+                               <MessageSquare class={ICON_CLASS_DEFAULT} />
 
                                <span>System Message</span>
                        </DropdownMenu.Item>
                                        class="flex cursor-pointer items-center gap-2"
                                        onclick={onMcpPromptClick}
                                >
-                                       <Zap class="h-4 w-4" />
+                                       <Zap class={ICON_CLASS_DEFAULT} />
 
                                        <span>MCP Prompt</span>
                                </DropdownMenu.Item>
                                        class="flex cursor-pointer items-center gap-2"
                                        onclick={onMcpResourcesClick}
                                >
-                                       <FolderOpen class="h-4 w-4" />
+                                       <FolderOpen class={ICON_CLASS_DEFAULT} />
 
                                        <span>MCP Resources</span>
                                </DropdownMenu.Item>
index ea4494ce5d12477b657dc7ff2bf3884f2badb6ec..de1ced1723198b9ec60b2d60db8cc5a027d11be1 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Settings, Plus } from '@lucide/svelte';
        import { Switch } from '$lib/components/ui/switch';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -60,7 +61,7 @@
 <DropdownMenu.Root>
        <DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
                <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
-                       <McpLogo class="h-4 w-4" />
+                       <McpLogo class={ICON_CLASS_DEFAULT} />
 
                        <span>MCP Servers</span>
                </DropdownMenu.SubTrigger>
@@ -92,7 +93,7 @@
                                                                                <McpServerIdentity
                                                                                        {displayName}
                                                                                        {faviconUrl}
-                                                                                       iconClass="h-4 w-4"
+                                                                                       iconClass={ICON_CLASS_DEFAULT}
                                                                                        iconRounded="rounded-sm"
                                                                                        showVersion={false}
                                                                                        nameClass="text-sm"
                                                        class="flex cursor-pointer items-center gap-2"
                                                        onclick={handleMcpSettingsClick}
                                                >
-                                                       <Settings class="h-4 w-4" />
+                                                       <Settings class={ICON_CLASS_DEFAULT} />
 
                                                        <span>Manage MCP Servers</span>
                                                </DropdownMenu.Item>
                                        class="flex cursor-pointer items-center gap-2"
                                        onclick={handleMcpSettingsClick}
                                >
-                                       <Plus class="h-4 w-4" />
+                                       <Plus class={ICON_CLASS_DEFAULT} />
 
                                        <span>Add MCP Servers</span>
                                </DropdownMenu.Item>
index caea7a0227cc11d5e3e25eae348db298bfad8921..6eb29d492ae79be24dc3f27234f53c43bab9377b 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -13,9 +14,9 @@
        <DropdownMenu.Sub bind:open={subOpen}>
                <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
                        {#if reasoning.thinkingEnabled}
-                               <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+                               <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
                        {:else}
-                               <LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
+                               <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
                        {/if}
 
                        <span
@@ -46,9 +47,9 @@
                                        }}
                                >
                                        {#if reasoning.isSelected(level)}
-                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
+                                               <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
                                        {:else}
-                                               <div class="h-4 w-4 shrink-0"></div>
+                                               <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
                                        {/if}
 
                                        <span class="flex-1">{level.label}</span>
index 6ee8eb578f8908b53c28a145d29111db30eae518..c533acc750050ced8c9878048fd3411a6e318fb1 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import type { Snippet } from 'svelte';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import * as Sheet from '$lib/components/ui/sheet';
                                        >
                                                <Collapsible.Trigger class={sheetItemClass}>
                                                        {#if reasoningExpanded}
-                                                               <ChevronDown class="h-4 w-4 shrink-0" />
+                                                               <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                        {:else}
-                                                               <ChevronRight class="h-4 w-4 shrink-0" />
+                                                               <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                        {/if}
 
                                                        {#if reasoning.thinkingEnabled}
-                                                               <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+                                                               <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
                                                        {:else}
-                                                               <LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
+                                                               <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
                                                        {/if}
 
                                                        <span class="flex-1">Reasoning</span>
                                                                        >
                                                                                <div class="flex min-w-0 items-center gap-3">
                                                                                        {#if reasoning.isSelected(level)}
-                                                                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
+                                                                                               <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
                                                                                        {:else}
-                                                                                               <div class="h-4 w-4 shrink-0"></div>
+                                                                                               <div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
                                                                                        {/if}
 
                                                                                        <span class="text-sm">{level.label}</span>
                                <Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
                                        <Collapsible.Trigger class={sheetItemClass}>
                                                {#if filesExpanded}
-                                                       <ChevronDown class="h-4 w-4 shrink-0" />
+                                                       <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                {:else}
-                                                       <ChevronRight class="h-4 w-4 shrink-0" />
+                                                       <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                {/if}
 
-                                               <File class="h-4 w-4 shrink-0" />
+                                               <File class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span class="flex-1">Add files</span>
                                        </Collapsible.Trigger>
                                                                                class={sheetItemClass}
                                                                                onclick={() => attachmentMenu.callbacks[item.action]()}
                                                                        >
-                                                                               <item.icon class="h-4 w-4 shrink-0" />
+                                                                               <item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                                                                <span>{item.label}</span>
                                                                        </button>
                                                                        <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
                                                                                <Tooltip.Trigger>
                                                                                        <button type="button" class={sheetItemClass} disabled>
-                                                                                               <item.icon class="h-4 w-4 shrink-0" />
+                                                                                               <item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                                                                                <span>{item.label}</span>
                                                                                        </button>
                                <Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
                                        <Collapsible.Trigger class={sheetItemClass}>
                                                {#if mcpExpanded}
-                                                       <ChevronDown class="h-4 w-4 shrink-0" />
+                                                       <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                {:else}
-                                                       <ChevronRight class="h-4 w-4 shrink-0" />
+                                                       <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                {/if}
 
-                                               <McpLogo class="inline h-4 w-4 shrink-0" />
+                                               <McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span class="flex-1">MCP Servers</span>
 
                                                                                        <img
                                                                                                src={faviconUrl}
                                                                                                alt=""
-                                                                                               class="h-4 w-4 shrink-0 rounded-sm"
+                                                                                               class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
                                                                                                onerror={(e) => {
                                                                                                        (e.currentTarget as HTMLImageElement).style.display = 'none';
                                                                                                }}
                                        <Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
                                                <Collapsible.Trigger class={sheetItemClass}>
                                                        {#if toolsExpanded}
-                                                               <ChevronDown class="h-4 w-4 shrink-0" />
+                                                               <ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                        {:else}
-                                                               <ChevronRight class="h-4 w-4 shrink-0" />
+                                                               <ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
                                                        {/if}
 
-                                                       <PencilRuler class="inline h-4 w-4 shrink-0" />
+                                                       <PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
 
                                                        <span class="flex-1">Tools</span>
 
                                                                                        <img
                                                                                                src={favicon}
                                                                                                alt=""
-                                                                                               class="h-4 w-4 shrink-0 rounded-sm"
+                                                                                               class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
                                                                                                onerror={(e) => {
                                                                                                        (e.currentTarget as HTMLImageElement).style.display = 'none';
                                                                                                }}
 
                                                                                <Checkbox
                                                                                        {checked}
-                                                                                       class="h-4 w-4 shrink-0"
+                                                                                       class="{ICON_CLASS_DEFAULT} shrink-0"
                                                                                        onclick={(e) => e.stopPropagation()}
                                                                                        onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
                                                                                />
                                        class={sheetItemClass}
                                        onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
                                >
-                                       <MessageSquare class="h-4 w-4 shrink-0" />
+                                       <MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                        <span>System Message</span>
                                </button>
                                                class={sheetItemClass}
                                                onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
                                        >
-                                               <Zap class="h-4 w-4 shrink-0" />
+                                               <Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span>MCP Prompt</span>
                                        </button>
                                                class={sheetItemClass}
                                                onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
                                        >
-                                               <FolderOpen class="h-4 w-4 shrink-0" />
+                                               <FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span>MCP Resources</span>
                                        </button>
index ece3e26530b0a0fa63dfa0c1bc62ae940f7ea780..9ca9360c599b00f6765ce45ef19c4b334c0fc69b 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import * as Collapsible from '$lib/components/ui/collapsible';
@@ -15,7 +16,7 @@
 
 <DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
        <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
-               <PencilRuler class="h-4 w-4" />
+               <PencilRuler class={ICON_CLASS_DEFAULT} />
 
                <span>Tools</span>
        </DropdownMenu.SubTrigger>
                {#if toolsPanel.totalToolCount === 0}
                        {#if toolsStore.loading}
                                <div class="px-3 py-4 text-center text-sm text-muted-foreground">
-                                       <Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
+                                       <Loader2 class="mx-auto mb-1 {ICON_CLASS_DEFAULT} animate-spin" />
 
                                        Loading tools...
                                </div>
                        {:else if toolsStore.isToolsEndpointUnreachable}
                                <div class="grid gap-2.5 px-3 py-4 text-sm text-muted-foreground">
                                        <span class="flex gap-2">
-                                               <Info class="mt-0.5 h-4 w-4 shrink-0" />
+                                               <Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span>
                                                        Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
@@ -41,7 +42,7 @@
                                        </span>
 
                                        <span class="flex gap-2">
-                                               <Info class="mt-0.5 h-4 w-4 shrink-0" />
+                                               <Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
 
                                                <span>
                                                        {hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
@@ -54,7 +55,7 @@
                                <div class="px-3 py-4 text-center text-sm text-muted-foreground">Failed to load tools</div>
                        {:else if toolsPanel.noToolsInfoMessage}
                                <div class="flex gap-2 px-3 py-4 text-sm text-muted-foreground">
-                                       <Info class="mt-0.5 h-4 w-4 shrink-0" />
+                                       <Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
 
                                        <span>{toolsPanel.noToolsInfoMessage}</span>
                                </div>
@@ -87,7 +88,7 @@
                                                                                <img
                                                                                        src={favicon}
                                                                                        alt=""
-                                                                                       class="h-4 w-4 shrink-0 rounded-sm"
+                                                                                       class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
                                                                                        onerror={(e) => {
                                                                                                (e.currentTarget as HTMLImageElement).style.display = 'none';
                                                                                        }}
                                                                                        {...props}
                                                                                        {checked}
                                                                                        onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
-                                                                                       class="mr-2 h-4 w-4 shrink-0"
+                                                                                       class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
                                                                                />
                                                                        {/snippet}
                                                                </Tooltip.Trigger>
index f1b084906d173d6c7f1f226208d0a7bcdeb58e3b..59a71409727e9848cc03ae6c9da6e63c46548a0e 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Mic, Square } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import * as Tooltip from '$lib/components/ui/tooltip';
@@ -36,9 +37,9 @@
                                <span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
 
                                {#if isRecording}
-                                       <Square class="h-4 w-4 animate-pulse fill-white" />
+                                       <Square class="{ICON_CLASS_DEFAULT} animate-pulse fill-white" />
                                {:else}
-                                       <Mic class="h-4 w-4" />
+                                       <Mic class={ICON_CLASS_DEFAULT} />
                                {/if}
                        </Button>
                </Tooltip.Trigger>
index 7be356f2a8daefc23b0227e68fe44062bda3e34c..d19d2c7125355429572346f92749355f96f1349f 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Square, SkipForward } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { ChatService } from '$lib/services';
                >
                        <span class="sr-only">Skip reasoning</span>
 
-                       <SkipForward class="h-4 w-4 stroke-muted-foreground group-hover:stroke-foreground" />
+                       <SkipForward
+                               class="{ICON_CLASS_DEFAULT} stroke-muted-foreground group-hover:stroke-foreground"
+                       />
                </Button>
        {/if}
 
index cfb01537c9401d6adf0d13e5e5819c3eed0ed9ff..0a4277e94f8198aa331485f1f1fb70ed7f96db42 100644 (file)
@@ -6,7 +6,7 @@
        import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
        import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
        import type { ReasoningEffortLevel } from '$lib/types';
-       import { DIALOG_SUBMENU_CONTENT } from '$lib/constants/css-classes';
+       import { DIALOG_SUBMENU_CONTENT, ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import {
                modelsStore,
                checkModelSupportsThinking,
@@ -74,9 +74,9 @@
        <DropdownMenu.Sub bind:open={subOpen}>
                <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
                        {#if thinkingEnabled}
-                               <Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
+                               <Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
                        {:else}
-                               <LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
+                               <LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
                        {/if}
 
                        <span class="flex-1">Thinking</span>
                                        {/if}
 
                                        {#if isSelected(level)}
-                                               <Check class="h-4 w-4 shrink-0 text-foreground" />
+                                               <Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
                                        {/if}
                                </button>
                        {/each}
index 2b4a3f9d393c13ed9cf414f44a02a9aaa4fce5af..560bf73abda5668b3269f551c2735ef9a199940e 100644 (file)
                        {isLastAssistantMessage}
                        {message}
                        {toolMessages}
-                       messageContent={message.content}
                        onConfirmDelete={handleConfirmDelete}
                        onContinue={handleContinue}
                        onCopy={handleCopy}
index 6670d9302dc9553e3206027cd6e7920402078d45..00578fcf1a1a3e508a06df9c96a82b7f54c32701 100644 (file)
@@ -2,23 +2,20 @@
        import {
                ChatMessageAgenticContent,
                ChatMessageActionIcons,
-               ChatMessageEditForm,
-               ChatMessageStatistics,
-               ModelBadge,
-               ModelsSelectorDropdown
+               ChatMessageAssistantModel,
+               ChatMessageAssistantProcessingInfo,
+               ChatMessageAssistantRawOutput,
+               ChatMessageAssistantStatistics,
+               ChatMessageEditForm
        } from '$lib/components/app';
        import { getMessageEditContext } from '$lib/contexts';
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
-       import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
-       import { AgenticSectionType, ChatMessageStatisticsMode } from '$lib/enums';
-       import { REASONING_TAGS } from '$lib/constants/agentic';
-       import { fade } from 'svelte/transition';
+       import { modelLoadProgressText } from '$lib/utils';
        import { MessageRole } from '$lib/enums';
        import { config } from '$lib/stores/settings.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
        import { modelsStore } from '$lib/stores/models.svelte';
-       import { ServerModelStatus } from '$lib/enums';
 
        import { hasAgenticContent } from '$lib/utils';
 
@@ -33,7 +30,6 @@
                isLastAssistantMessage?: boolean;
                message: DatabaseMessage;
                toolMessages?: DatabaseMessage[];
-               messageContent: string | undefined;
                onCopy: () => void;
                onConfirmDelete: () => void;
                onContinue?: () => void;
@@ -54,7 +50,6 @@
                isLastAssistantMessage = false,
                message,
                toolMessages = [],
-               messageContent,
                onConfirmDelete,
                onContinue,
                onCopy,
 
        let currentConfig = $derived(config());
        let isRouter = $derived(isRouterMode());
-       let showRawOutput = $state(false);
-
-       let rawOutputContent = $derived.by(() => {
-               const sections = deriveAgenticSections(message, toolMessages, [], false);
-               const parts: string[] = [];
-
-               for (const section of sections) {
-                       switch (section.type) {
-                               case AgenticSectionType.REASONING:
-                               case AgenticSectionType.REASONING_PENDING:
-                                       parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
-                                       break;
-
-                               case AgenticSectionType.TEXT:
-                                       parts.push(section.content);
-                                       break;
-
-                               case AgenticSectionType.TOOL_CALL:
-                               case AgenticSectionType.TOOL_CALL_PENDING:
-                               case AgenticSectionType.TOOL_CALL_STREAMING: {
-                                       const callObj: Record<string, unknown> = { name: section.toolName };
 
-                                       if (section.toolArgs) {
-                                               try {
-                                                       callObj.arguments = JSON.parse(section.toolArgs);
-                                               } catch {
-                                                       callObj.arguments = section.toolArgs;
-                                               }
-                                       }
-
-                                       parts.push(JSON.stringify(callObj, null, 2));
-
-                                       if (section.toolResult) {
-                                               parts.push(`[Tool Result]\n${section.toolResult}`);
-                                       }
-
-                                       break;
-                               }
-                       }
-               }
-
-               return parts.join('\n\n\n');
-       });
+       let showRawOutput = $state(false);
 
        let displayedModel = $derived(message.model ?? null);
 
-       // model being switched to while it loads, so the selector bar tracks it
-       let pendingModel = $state<string | null>(null);
-
        let isCurrentlyLoading = $derived(isLoading());
        let isStreaming = $derived(isChatStreaming());
        let hasNoContent = $derived(!message?.content?.trim());
                };
        });
 
-       function handleCopyModel() {
-               void copyToClipboard(displayedModel ?? '');
-       }
-
        $effect(() => {
                if (showProcessingInfoTop || showProcessingInfoBottom) {
                        processingState.startMonitoring();
        aria-label="Assistant message with actions"
 >
        {#if showProcessingInfoTop}
-               <div class="mt-6 w-full max-w-3xl" in:fade>
-                       <div class="processing-container">
-                               <span class="processing-text">
-                                       {modelLoadingText ??
-                                               processingState.getPromptProgressText() ??
-                                               processingState.getProcessingMessage() ??
-                                               'Processing...'}
-                               </span>
-                       </div>
-               </div>
+               <ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
        {/if}
 
        {#if editCtx.isEditing}
                <ChatMessageEditForm />
-       {:else if message.role === MessageRole.ASSISTANT}
+       {:else}
                {#if showRawOutput}
-                       <pre class="raw-output">{rawOutputContent || ''}</pre>
+                       <ChatMessageAssistantRawOutput {message} {toolMessages} />
                {:else}
                        <ChatMessageAgenticContent
                                {message}
                                {isLastAssistantMessage}
                        />
                {/if}
-       {:else}
-               <div class="text-sm whitespace-pre-wrap">
-                       {messageContent}
-               </div>
        {/if}
 
        {#if showProcessingInfoBottom}
-               <div class="mt-4 w-full max-w-3xl" in:fade>
-                       <div class="processing-container">
-                               <span class="processing-text">
-                                       {modelLoadingText ??
-                                               processingState.getPromptProgressText() ??
-                                               processingState.getProcessingMessage() ??
-                                               'Processing...'}
-                               </span>
-                       </div>
-               </div>
+               <ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
        {/if}
 
        <div class="info my-6 grid gap-4 tabular-nums">
                {#if displayedModel}
                        <div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
-                               {#if isRouter}
-                                       <ModelsSelectorDropdown
-                                               currentModel={pendingModel ?? displayedModel}
-                                               disabled={isLoading()}
-                                               onModelChange={async (modelId: string, modelName: string) => {
-                                                       const status = modelsStore.getModelStatus(modelId);
-
-                                                       if (status !== ServerModelStatus.LOADED) {
-                                                               pendingModel = modelId;
-
-                                                               try {
-                                                                       await modelsStore.loadModel(modelId);
-                                                               } finally {
-                                                                       pendingModel = null;
-                                                               }
-                                                       }
-
-                                                       onRegenerate(modelName);
-                                                       return true;
-                                               }}
-                                       />
-                               {:else}
-                                       <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
-                               {/if}
-
-                               {#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
-                                       {@const agentic = message.timings.agentic}
-                                       <ChatMessageStatistics
-                                               mode={ChatMessageStatisticsMode.GENERATION}
-                                               promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
-                                               promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
-                                               predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
-                                               predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
-                                               agenticTimings={agentic}
-                                       />
-                               {:else if isLoading() && currentConfig.showMessageStats}
-                                       {@const liveStats = processingState.getLiveProcessingStats()}
-                                       {@const genStats = processingState.getLiveGenerationStats()}
-
-                                       {#if genStats}
-                                               <ChatMessageStatistics
-                                                       mode={ChatMessageStatisticsMode.GENERATION}
-                                                       isLive
-                                                       promptTokens={liveStats?.tokensProcessed}
-                                                       promptMs={liveStats?.timeMs}
-                                                       predictedTokens={genStats.tokensGenerated}
-                                                       predictedMs={genStats.timeMs}
-                                               />
-                                       {/if}
-                               {/if}
+                               <ChatMessageAssistantModel
+                                       {displayedModel}
+                                       isLoading={isLoading()}
+                                       {isRouter}
+                                       {onRegenerate}
+                               />
+
+                               <ChatMessageAssistantStatistics
+                                       {message}
+                                       isLoading={isLoading()}
+                                       {processingState}
+                                       showMessageStats={currentConfig.showMessageStats}
+                               />
                        </div>
                {/if}
        </div>
                        );
                }
        }
-
-       .processing-container {
-               display: flex;
-               flex-direction: column;
-               align-items: flex-start;
-               gap: 0.5rem;
-       }
-
-       .processing-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;
-               animation: shine 1s linear infinite;
-               font-weight: 500;
-               font-size: 0.875rem;
-       }
-
-       @keyframes shine {
-               to {
-                       background-position: -200% 0;
-               }
-       }
-
-       .raw-output {
-               width: 100%;
-               max-width: 48rem;
-               margin-top: 1.5rem;
-               padding: 1rem 1.25rem;
-               border-radius: 1rem;
-               background: hsl(var(--muted) / 0.3);
-               color: var(--foreground);
-               font-size: 0.875rem;
-               line-height: 1.6;
-               white-space: pre-wrap;
-               word-break: break-word;
-       }
 </style>
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte
new file mode 100644 (file)
index 0000000..76b45ec
--- /dev/null
@@ -0,0 +1,46 @@
+<script lang="ts">
+       import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
+       import { copyToClipboard } from '$lib/utils';
+       import { modelsStore } from '$lib/stores/models.svelte';
+       import { ServerModelStatus } from '$lib/enums';
+
+       interface Props {
+               displayedModel: string | null;
+               isRouter: boolean;
+               isLoading: boolean;
+               onRegenerate: (modelOverride?: string) => void;
+       }
+
+       let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
+
+       let pendingModel = $state<string | null>(null);
+
+       function handleCopyModel() {
+               void copyToClipboard(displayedModel ?? '');
+       }
+</script>
+
+{#if isRouter}
+       <ModelsSelectorDropdown
+               currentModel={pendingModel ?? displayedModel}
+               disabled={isLoading}
+               onModelChange={async (modelId: string, modelName: string) => {
+                       const status = modelsStore.getModelStatus(modelId);
+
+                       if (status !== ServerModelStatus.LOADED) {
+                               pendingModel = modelId;
+
+                               try {
+                                       await modelsStore.loadModel(modelId);
+                               } finally {
+                                       pendingModel = null;
+                               }
+                       }
+
+                       onRegenerate(modelName);
+                       return true;
+               }}
+       />
+{:else}
+       <ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
+{/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 (file)
index 0000000..356512e
--- /dev/null
@@ -0,0 +1,25 @@
+<script lang="ts">
+       import { fade } from 'svelte/transition';
+       import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
+
+       interface Props {
+               modelLoadingText: string | null;
+               processingState: UseProcessingStateReturn;
+               position: 'top' | 'bottom';
+       }
+
+       let { modelLoadingText, processingState, position }: Props = $props();
+
+       const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
+</script>
+
+<div class="{marginClass} w-full max-w-3xl" in:fade>
+       <div class="flex flex-col items-start gap-2">
+               <span class="shimmer-text text-sm">
+                       {modelLoadingText ??
+                               processingState.getPromptProgressText() ??
+                               processingState.getProcessingMessage() ??
+                               'Processing...'}
+               </span>
+       </div>
+</div>
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 (file)
index 0000000..30ce16b
--- /dev/null
@@ -0,0 +1,33 @@
+<script lang="ts">
+       import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
+
+       interface Props {
+               message: DatabaseMessage;
+               toolMessages?: DatabaseMessage[];
+       }
+
+       let { message, toolMessages = [] }: Props = $props();
+
+       let rawOutputContent = $derived.by(() => {
+               const sections = deriveAgenticSections(message, toolMessages, [], false);
+               return buildAssistantRawOutput(sections);
+       });
+</script>
+
+<pre class="raw-output">{rawOutputContent || ''}</pre>
+
+<style>
+       .raw-output {
+               width: 100%;
+               max-width: 48rem;
+               margin-top: 1.5rem;
+               padding: 1rem 1.25rem;
+               border-radius: 1rem;
+               background: hsl(var(--muted) / 0.3);
+               color: var(--foreground);
+               font-size: 0.875rem;
+               line-height: 1.6;
+               white-space: pre-wrap;
+               word-break: break-word;
+       }
+</style>
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 (file)
index 0000000..4cc4080
--- /dev/null
@@ -0,0 +1,40 @@
+<script lang="ts">
+       import { ChatMessageStatistics } from '$lib/components/app';
+       import { ChatMessageStatisticsMode } from '$lib/enums';
+       import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
+
+       interface Props {
+               message: DatabaseMessage;
+               isLoading: boolean;
+               processingState: UseProcessingStateReturn;
+               showMessageStats: boolean;
+       }
+
+       let { message, isLoading, processingState, showMessageStats }: Props = $props();
+</script>
+
+{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
+       {@const agentic = message.timings.agentic}
+       <ChatMessageStatistics
+               mode={ChatMessageStatisticsMode.GENERATION}
+               promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
+               promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
+               predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
+               predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
+               agenticTimings={agentic}
+       />
+{:else if isLoading && showMessageStats}
+       {@const liveStats = processingState.getLiveProcessingStats()}
+       {@const genStats = processingState.getLiveGenerationStats()}
+
+       {#if genStats}
+               <ChatMessageStatistics
+                       mode={ChatMessageStatisticsMode.GENERATION}
+                       isLive
+                       promptTokens={liveStats?.tokensProcessed}
+                       promptMs={liveStats?.timeMs}
+                       predictedTokens={genStats.tokensGenerated}
+                       predictedMs={genStats.timeMs}
+               />
+       {/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 (file)
index 0000000..b1daedf
--- /dev/null
@@ -0,0 +1,66 @@
+<script lang="ts">
+       import { BuiltInTool } from '$lib/enums';
+       import {
+               extractSearchQuery,
+               extractSearchResults,
+               isWebSearchToolName,
+               type AgenticSection
+       } from '$lib/utils';
+       import type { DatabaseMessageExtra } from '$lib/types';
+       import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
+       import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
+       import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
+       import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
+       import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
+       import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
+       import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
+       import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
+       import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
+       import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               attachments?: DatabaseMessageExtra[];
+               open: boolean;
+               isStreaming: boolean;
+               isExecuting?: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props();
+
+       const searchResults = $derived(extractSearchResults(section.toolResult));
+       const searchQuery = $derived(extractSearchQuery(section.toolArgs));
+       const isSearchCall = $derived(
+               searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName))
+       );
+</script>
+
+{#if isSearchCall}
+       <ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.GET_DATETIME}
+       <ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
+{:else if section.toolName === BuiltInTool.READ_FILE}
+       <ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.EDIT_FILE}
+       <ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.WRITE_FILE}
+       <ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.EXEC_SHELL_COMMAND}
+       <ChatMessageToolCallBlockExecShellCommand
+               {section}
+               {open}
+               {isStreaming}
+               {isExecuting}
+               {attachments}
+               {onToggle}
+       />
+{:else if section.toolName === BuiltInTool.FILE_GLOB_SEARCH}
+       <ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.GREP_SEARCH}
+       <ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
+{:else if section.toolName === BuiltInTool.RUN_JAVASCRIPT}
+       <ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
+{:else}
+       <ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
+{/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 (file)
index 0000000..acf2de1
--- /dev/null
@@ -0,0 +1,124 @@
+<script lang="ts">
+       // Fall-through renderer for tool calls without a dedicated block.
+       // Renders section.toolArgs / section.toolResult directly using the
+       // shared chrome shell.
+
+       import { Loader2 } from '@lucide/svelte';
+       import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
+       import { FileTypeText, ToolResultKind } from '$lib/enums';
+       import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
+       import {
+               classifyToolResult,
+               formatJsonPretty,
+               parseToolResultWithImages,
+               type AgenticSection,
+               type ToolResultLine
+       } from '$lib/utils';
+       import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
+       import type { DatabaseMessageExtra } from '$lib/types';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               attachments?: DatabaseMessageExtra[];
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, attachments, onToggle }: Props = $props();
+
+       const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
+
+       const parsedLines: ToolResultLine[] = $derived(
+               section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
+       );
+       const outputKind = $derived(classifyToolResult(section.toolResult));
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
+       {#snippet children(_meta, ctx)}
+               {#if ctx.isStreamingCall}
+                       <div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
+                               <span>Input</span>
+                               {#if ctx.isStreaming}
+                                       <Loader2 class="h-3 w-3 animate-spin" />
+                               {/if}
+                       </div>
+                       {#if section.toolArgs}
+                               <SyntaxHighlightedCode
+                                       code={formatJsonPretty(section.toolArgs)}
+                                       language={FileTypeText.JSON}
+                                       maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                                       streaming={ctx.isCodeStreaming}
+                               />
+                       {:else if ctx.isStreaming}
+                               <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+                                       Receiving arguments...
+                               </div>
+                       {:else}
+                               <div
+                                       class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
+                               >
+                                       Response was truncated
+                               </div>
+                       {/if}
+               {:else}
+                       {@const showInput = Boolean(section.toolArgs)}
+                       {#if showInput}
+                               <div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
+                                       <span>Input</span>
+                               </div>
+                               <SyntaxHighlightedCode
+                                       code={formatJsonPretty(section.toolArgs ?? '')}
+                                       language={FileTypeText.JSON}
+                                       maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                                       streaming={ctx.isCodeStreaming}
+                               />
+                       {/if}
+                       <div
+                               class={showInput
+                                       ? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
+                                       : 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
+                       >
+                               <span>Output</span>
+                               {#if ctx.isPending}
+                                       <Loader2 class="h-3 w-3 animate-spin" />
+                               {/if}
+                       </div>
+                       {#if ctx.isPending}
+                               <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+                                       Waiting for result...
+                               </div>
+                       {:else if section.toolResult}
+                               {#if outputKind === ToolResultKind.JSON}
+                                       <SyntaxHighlightedCode
+                                               code={formatJsonPretty(section.toolResult)}
+                                               language={FileTypeText.JSON}
+                                               maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                                       />
+                               {:else if outputKind === ToolResultKind.MARKDOWN}
+                                       <MarkdownContent content={section.toolResult} {attachments} />
+                               {:else}
+                                       <div class="overflow-auto">
+                                               {#each parsedLines as line, i (i)}
+                                                       <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
+                                                               {line.text}
+                                                       </div>
+                                                       {#if line.image}
+                                                               <img
+                                                                       src={line.image.base64Url}
+                                                                       alt={line.image.name}
+                                                                       class="mt-2 mb-2 h-auto max-w-full rounded-lg"
+                                                                       loading="lazy"
+                                                               />
+                                                       {/if}
+                                               {/each}
+                                       </div>
+                               {/if}
+                       {:else}
+                               <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
+                       {/if}
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..6f30060
--- /dev/null
@@ -0,0 +1,166 @@
+<script lang="ts">
+       import { XCircle } from '@lucide/svelte';
+       import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
+       import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
+       import { parseEditFileMeta } from './parsers/edit-file';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const editFileMeta = $derived(parseEditFileMeta(section));
+
+       const editDiffs = $derived(
+               (editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
+       );
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
+       {#snippet titleSnippet()}
+               <span class="text-muted-foreground">Edit file </span>
+               <span class="font-mono">{editFileMeta?.filePath}</span>
+               {#if editFileMeta?.errorMessage}
+                       <span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
+               {/if}
+       {/snippet}
+
+       {#snippet children(meta, _ctx)}
+               {#if meta?.errorMessage}
+                       <div
+                               class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+                       >
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{meta.errorMessage}</span>
+                       </div>
+               {:else if meta && meta.edits.length > 0}
+                       {#each editDiffs as diffLines, ei (ei)}
+                               <div class={ei === 0 ? '' : 'mt-3'}>
+                                       <div class="mb-1.5 text-xs text-muted-foreground/70 italic">
+                                               Edit {ei + 1}&nbsp;of&nbsp;{meta.edits.length}
+                                       </div>
+                                       <div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
+                                               <div class="diff-pre">
+                                                       {#each diffLines as line, li (li)}
+                                                               <div class="diff-line diff-{line.kind}">
+                                                                       <span class="diff-old-num">{line.oldLine ?? ''}</span>
+                                                                       <span class="diff-marker">{prefixFor(line.kind)}</span>
+                                                                       <span class="diff-new-num">{line.newLine ?? ''}</span>
+                                                                       <span class="diff-text">{line.text || ' '}</span>
+                                                               </div>
+                                                       {/each}
+                                               </div>
+                                       </div>
+                               </div>
+                       {/each}
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               {#if meta.resultMessage}
+                                       {meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
+                               {#if meta.editsApplied != null}
+                                       <span class="font-mono">{meta.editsApplied}</span>
+                                       {meta.editsApplied === 1 ? 'edit' : 'edits'}&nbsp;applied
+                               {/if}
+                       </div>
+               {:else}
+                       <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No edits</div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
+
+<style>
+       .diff-block {
+               overflow: auto;
+               border-radius: 0.75rem;
+               border-width: 1px;
+               border-color: color-mix(in oklch, var(--border) 30%, transparent);
+               background: var(--code-background);
+               box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+       }
+
+       :global(.dark) .diff-block {
+               border-color: color-mix(in oklch, var(--border) 20%, transparent);
+       }
+
+       /* Each row is a 4-column grid: old-line#, marker, new-line#, text.
+        * The gutters stay fixed-width so the text column lines up unversally. */
+       .diff-line {
+               display: grid;
+               grid-template-columns: 3.25rem 1.5rem 3.25rem 1fr;
+               font-family: var(--font-mono);
+               font-size: 11px;
+               line-height: 1.65;
+               align-items: stretch;
+       }
+
+       .diff-old-num,
+       .diff-new-num {
+               text-align: right;
+               padding-right: 0.5rem;
+               user-select: none;
+               color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
+               font-variant-numeric: tabular-nums;
+       }
+
+       .diff-marker {
+               text-align: center;
+               color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
+               user-select: none;
+       }
+
+       .diff-line.diff-add {
+               background-color: #f0fff4;
+               color: #22863a;
+       }
+       .diff-line.diff-add .diff-new-num,
+       .diff-line.diff-add .diff-marker {
+               color: #22863a;
+       }
+
+       .diff-line.diff-remove {
+               background-color: #ffeef0;
+               color: #b31d28;
+       }
+       .diff-line.diff-remove .diff-old-num,
+       .diff-line.diff-remove .diff-marker {
+               color: #b31d28;
+       }
+
+       .diff-line.diff-add .diff-old-num,
+       .diff-line.diff-remove .diff-new-num {
+               /* Empty gutter columns for add/remove rows mirror git unification
+                * (added lines don't have an old number, removed lines don't have a
+                * new number). Keep them visible so columns stay aligned across
+                * mixed rows. */
+               opacity: 0;
+       }
+
+       .diff-text {
+               padding-left: 0.4rem;
+               padding-right: 0.5rem;
+               white-space: pre;
+               overflow-x: auto;
+               min-width: 0;
+       }
+
+       :global(.dark) .diff-line.diff-add {
+               background-color: #033a16;
+               color: #aff5b4;
+       }
+       :global(.dark) .diff-line.diff-add .diff-new-num,
+       :global(.dark) .diff-line.diff-add .diff-marker {
+               color: #aff5b4;
+       }
+       :global(.dark) .diff-line.diff-remove {
+               background-color: #67060c;
+               color: #ffdcd7;
+       }
+       :global(.dark) .diff-line.diff-remove .diff-old-num,
+       :global(.dark) .diff-line.diff-remove .diff-marker {
+               color: #ffdcd7;
+       }
+</style>
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 (file)
index 0000000..5de801d
--- /dev/null
@@ -0,0 +1,293 @@
+<script lang="ts">
+       // Block for `exec_shell_command`. Unlike the other tools, this
+       // renderer uses CollapsibleTerminalBlock (terminal-style frame)
+       // and treats "live" output chunks as active even after the call
+       // resolved, so the spinner stays on while stdout is still flowing.
+       // The scroll-to-bottom auto-scroll logic mirrors what was here
+       // before extraction.
+
+       import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
+       import { CollapsibleTerminalBlock } from '$lib/components/app';
+       import { SETTINGS_KEYS } from '$lib/constants';
+       import { config } from '$lib/stores/settings.svelte';
+       import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+       import {
+               highlightCode,
+               isExitCodeSummaryLine,
+               parseExecShellCommandError,
+               parseExecShellCommandExitStatus,
+               parseToolResultWithImages,
+               type AgenticSection,
+               type ExecShellExitStatus,
+               type ToolResultLine
+       } from '$lib/utils';
+       import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
+       import type { DatabaseMessageExtra } from '$lib/types';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               /** True while the agentic loop is streaming output chunks for THIS
+                *  tool call. Drives max-height + auto-scroll while true; releases
+                *  them when the loop reports this call as done. */
+               isExecuting?: boolean;
+               attachments?: DatabaseMessageExtra[];
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props();
+
+       // `isLive` covers all in-flight phases: pre-chunk spinner and
+       // streaming itself. Frozen output (tool done while agent continues)
+       // is not live.
+       const isLive = $derived(isExecuting);
+
+       const execShellMeta = $derived(parseExecShellCommandMeta(section));
+       const execShellError = $derived(parseExecShellCommandError(section.toolResult));
+       const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
+               parseExecShellCommandExitStatus(section.toolResult)
+       );
+
+       const parsedLines: ToolResultLine[] = $derived(
+               section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
+       );
+
+       // Drop the trailing "[exit code: N]" line - rendered as a colored
+       // badge below. During streaming we keep it so a partial stream still
+       // shows the status once the final chunk lands.
+       const outputLines: ToolResultLine[] = $derived(
+               execShellExitStatus && parsedLines.length > 0
+                       ? parsedLines.slice(0, parsedLines.length - 1)
+                       : parsedLines
+       );
+
+       const isExitCodeFinalLine = $derived(
+               execShellExitStatus !== undefined &&
+                       parsedLines.length > 0 &&
+                       isExitCodeSummaryLine(parsedLines[parsedLines.length - 1].text, execShellExitStatus)
+       );
+
+       // Highlight just the command for the title; the (typically large)
+       // output blob uses bare monospace to skip hljs per-line highlighting.
+       const highlightedCommandHtml = $derived(
+               execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
+       );
+
+       const exitBadgeClass = $derived(
+               execShellExitStatus?.timedOut
+                       ? 'exit-badge warning'
+                       : execShellExitStatus?.code === 0
+                               ? 'exit-badge success'
+                               : 'exit-badge failure'
+       );
+
+       const useFullHeightCodeBlocks = $derived(
+               Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
+       );
+
+       const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
+
+       const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
+
+       let scrollEl: HTMLDivElement | undefined = $state();
+       let userScrolledUp = $state(false);
+       let lastScrollTop = 0;
+       let pendingFrame: number | null = null;
+
+       function isAtBottom(): boolean {
+               if (!scrollEl) return false;
+               return (
+                       scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
+                       SCROLL_BOTTOM_THRESHOLD_PX
+               );
+       }
+
+       function scrollToBottomOnFrame() {
+               if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+               pendingFrame = requestAnimationFrame(() => {
+                       pendingFrame = null;
+
+                       // Re-check on rAF - user may scroll between scheduling and paint.
+                       if (scrollEl && !userScrolledUp) {
+                               scrollEl.scrollTop = scrollEl.scrollHeight;
+                       }
+               });
+       }
+
+       function handleScrollEvent() {
+               if (!scrollEl) return;
+               const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+               if (isScrollingUp && !isAtBottom()) {
+                       userScrolledUp = true;
+               } else if (isAtBottom()) {
+                       userScrolledUp = false;
+               }
+               lastScrollTop = scrollEl.scrollTop;
+       }
+
+       $effect(() => {
+               void section.toolResult;
+               if (!scrollEl || !autoScroll) return;
+               scrollToBottomOnFrame();
+       });
+
+       $effect(() => {
+               // Catch layout changes that don't touch toolResult (line-wrap
+               // reflow, image attaches, hljs settle).
+               if (!scrollEl || !autoScroll) return;
+
+               const observer = new MutationObserver(() => scrollToBottomOnFrame());
+               observer.observe(scrollEl, {
+                       childList: true,
+                       subtree: true,
+                       characterData: true
+               });
+
+               return () => observer.disconnect();
+       });
+
+       $effect(() => {
+               // Reset on stream end so the next render (full-height) starts
+               // pinned.
+               if (!isLive) {
+                       userScrolledUp = false;
+                       lastScrollTop = 0;
+               }
+       });
+</script>
+
+{#snippet execShellTitle()}
+       {#if highlightedCommandHtml}
+               <span class="font-mono">{@html highlightedCommandHtml}</span>
+       {:else}
+               <span class="font-mono">{execShellMeta?.command}</span>
+       {/if}
+{/snippet}
+
+<ToolCallBlock
+       {section}
+       {open}
+       {isStreaming}
+       meta={execShellMeta ? { errorMessage: execShellError } : null}
+       wrapper={CollapsibleTerminalBlock}
+       extraLiveStreaming={isLive}
+       spinIconWhenActive={true}
+       {onToggle}
+>
+       {#snippet titleSnippet()}
+               {@render execShellTitle()}
+       {/snippet}
+
+       {#snippet children(_meta, ctx)}
+               {#if ctx.isPending}
+                       <div class="flex items-start gap-2 text-xs text-muted-foreground/70">
+                               <Loader2 class="h-3 w-3 animate-spin" />
+                               Running...
+                       </div>
+               {:else if execShellError}
+                       <div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{execShellError}</span>
+                       </div>
+               {:else if section.toolResult}
+                       <div
+                               bind:this={scrollEl}
+                               class="terminal-output"
+                               class:is-clamped={!useFullHeightCodeBlocks}
+                               onscroll={handleScrollEvent}
+                       >
+                               {#each outputLines as line, i (i)}
+                                       <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
+                                       {#if line.image}
+                                               <img
+                                                       src={line.image.base64Url}
+                                                       alt={line.image.name}
+                                                       class="mt-2 mb-2 h-auto max-w-full rounded-lg"
+                                                       loading="lazy"
+                                               />
+                                       {/if}
+                               {/each}
+
+                               {#if isExitCodeFinalLine && execShellExitStatus}
+                                       <div class={exitBadgeClass}>
+                                               {#if execShellExitStatus.timedOut}
+                                                       <AlertTriangle class="h-3 w-3" />
+                                                       <span>timed out</span>
+                                                       <span class="exit-sep">&middot;</span>
+                                                       <span>exit {execShellExitStatus.code}</span>
+                                               {:else if execShellExitStatus.code === 0}
+                                                       <Check class="h-3 w-3" />
+                                                       <span>exit 0</span>
+                                               {:else}
+                                                       <XCircle class="h-3 w-3" />
+                                                       <span>exit {execShellExitStatus.code}</span>
+                                               {/if}
+                                       </div>
+                               {/if}
+                       </div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
+
+<style>
+       .terminal-output {
+               overscroll-behavior: contain;
+       }
+
+       .terminal-output.is-clamped {
+               max-height: 28rem;
+               overflow-y: auto;
+               scrollbar-gutter: stable;
+               padding-right: 0.25rem;
+       }
+
+       .exit-badge {
+               display: inline-flex;
+               align-items: center;
+               gap: 0.35rem;
+               margin-top: 0.5rem;
+               padding: 0.2rem 0.55rem;
+               border-radius: 0.375rem;
+               font-family: var(--font-mono);
+               font-size: 11px;
+               font-weight: 500;
+               letter-spacing: 0.01em;
+               line-height: 1;
+       }
+
+       .exit-badge.success {
+               background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
+               color: var(--color-green-700, #15803d);
+       }
+
+       :global(.dark) .exit-badge.success {
+               background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
+               color: var(--color-green-300, #86efac);
+       }
+
+       .exit-badge.failure {
+               background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
+               color: var(--color-red-700, #b91c1c);
+       }
+
+       :global(.dark) .exit-badge.failure {
+               background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
+               color: var(--color-red-300, #fca5a5);
+       }
+
+       .exit-badge.warning {
+               background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
+               color: var(--color-amber-700, #b45309);
+       }
+
+       :global(.dark) .exit-badge.warning {
+               background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
+               color: var(--color-amber-300, #fcd34d);
+       }
+
+       .exit-sep {
+               opacity: 0.45;
+       }
+</style>
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 (file)
index 0000000..ad08203
--- /dev/null
@@ -0,0 +1,61 @@
+<script lang="ts">
+       import { XCircle } from '@lucide/svelte';
+       import { type AgenticSection } from '$lib/utils';
+       import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
+       {#snippet titleSnippet()}
+               {#if fileGlobMeta}
+                       <span class="text-muted-foreground"
+                               >{fileGlobMeta.include === '**' ? 'List files' : 'Search files'}&nbsp;</span
+                       >
+                       {#if fileGlobMeta.include !== '**'}
+                               <span class="font-mono">{fileGlobMeta.include}</span>
+                       {/if}
+                       <span class="text-muted-foreground">&nbsp;in&nbsp;</span>
+                       <span class="font-mono">{fileGlobMeta.path}</span>
+               {/if}
+       {/snippet}
+
+       {#snippet children(meta, ctx)}
+               {#if ctx.isPending}
+                       <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+                               Searching...
+                       </div>
+               {:else if meta?.errorMessage}
+                       <div
+                               class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+                       >
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{meta.errorMessage}</span>
+                       </div>
+               {:else if meta && meta.matches.length > 0}
+                       <div class="max-h-96 overflow-auto">
+                               {#each meta.matches as match, i (i)}
+                                       <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
+                               {/each}
+                       </div>
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
+                       </div>
+               {:else}
+                       <div class="text-xs text-muted-foreground/70 italic">No matches</div>
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
+                       </div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..e0c701d
--- /dev/null
@@ -0,0 +1,57 @@
+<script lang="ts">
+       import { Clock, Loader2 } from '@lucide/svelte';
+       import { AgenticSectionType } from '$lib/enums';
+       import type { AgenticSection } from '$lib/utils';
+
+       interface Props {
+               section: AgenticSection;
+               isStreaming?: boolean;
+       }
+
+       let { section, isStreaming = false }: Props = $props();
+
+       const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
+       const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
+       const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
+
+       type GetDatetimeMeta = {
+               dateString?: string;
+               errorMessage?: string;
+       };
+
+       function parseGetDatetimeMeta(toolResultString: string | undefined): GetDatetimeMeta {
+               if (!toolResultString) return {};
+
+               try {
+                       const parsed: unknown = JSON.parse(toolResultString);
+                       if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                               const obj = parsed as Record<string, unknown>;
+                               if (typeof obj.error === 'string') return { errorMessage: obj.error };
+                               if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
+                       }
+               } catch {
+                       return { dateString: toolResultString.trim() };
+               }
+
+               return {};
+       }
+
+       const dateMeta = $derived(parseGetDatetimeMeta(section.toolResult));
+</script>
+
+<div class="text-muted-foreground flex items-center gap-2 py-1.5">
+       <Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
+       {#if showSpinner}
+               <span class="text-foreground/80 text-sm font-medium">Current time</span>
+               <Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
+       {:else if dateMeta.errorMessage}
+               <span class="text-foreground/80 text-sm font-medium">Current time&nbsp;</span>
+               <span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{dateMeta.errorMessage}</span
+               >
+       {:else if dateMeta.dateString}
+               <span class="text-foreground/80 text-sm font-medium">Current time is&nbsp;</span>
+               <span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
+       {:else}
+               <span class="text-foreground/80 text-sm font-medium">Current time</span>
+       {/if}
+</div>
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 (file)
index 0000000..afb06fe
--- /dev/null
@@ -0,0 +1,67 @@
+<script lang="ts">
+       import { XCircle } from '@lucide/svelte';
+       import { type AgenticSection } from '$lib/utils';
+       import { parseGrepSearchMeta } from './parsers/grep-search';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const grepMeta = $derived(parseGrepSearchMeta(section));
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
+       {#snippet titleSnippet()}
+               {#if grepMeta}
+                       <span class="text-muted-foreground">Search for&nbsp;</span>
+                       <span class="font-mono">{grepMeta.pattern}</span>
+                       <span class="text-muted-foreground">&nbsp;in&nbsp;</span>
+                       <span class="font-mono">{grepMeta.path}</span>
+               {/if}
+       {/snippet}
+
+       {#snippet children(meta, ctx)}
+               {#if ctx.isPending}
+                       <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+                               Searching...
+                       </div>
+               {:else if meta?.errorMessage}
+                       <div
+                               class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+                       >
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{meta.errorMessage}</span>
+                       </div>
+               {:else if meta && meta.matches.length > 0}
+                       <div class="max-h-96 overflow-auto">
+                               {#each meta.matches as match, mi (mi)}
+                                       <div class="font-mono text-[11px] leading-relaxed">
+                                               <span class="text-muted-foreground/70">{match.file}</span>
+                                               {#if meta.showLineNumbers && match.line != null}
+                                                       <span class="text-muted-foreground/70">:{match.line}</span>
+                                               {/if}
+                                               <span class="text-muted-foreground/70">:</span>
+                                               <span>{match.content}</span>
+                                       </div>
+                               {/each}
+                       </div>
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
+                               {#if meta.showLineNumbers}
+                                       &nbsp;<span class="italic">(with line numbers)</span>
+                               {/if}
+                       </div>
+               {:else}
+                       <div class="text-xs text-muted-foreground/70 italic">No matches</div>
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
+                       </div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..a99ff9c
--- /dev/null
@@ -0,0 +1,44 @@
+<script lang="ts">
+       import { SyntaxHighlightedCode } from '$lib/components/app';
+       import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
+       import { type AgenticSection } from '$lib/utils';
+       import { parseReadFileMeta } from './parsers/read-file';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const readFileMeta = $derived(parseReadFileMeta(section));
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
+       {#snippet titleSnippet()}
+               <span class="text-muted-foreground">Read file </span>
+               <span class="font-mono">{readFileMeta?.fileName}</span>
+               {#if readFileMeta?.lineRange}
+                       <span class="text-muted-foreground"
+                               >&nbsp;(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
+                       >
+               {/if}
+       {/snippet}
+
+       {#snippet children(_meta, _ctx)}
+               {#if section.toolResult}
+                       <SyntaxHighlightedCode
+                               code={section.toolResult}
+                               language={readFileMeta?.language ?? DEFAULT_LANGUAGE}
+                               maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                       />
+               {:else}
+                       <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+                               Waiting for file content...
+                       </div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..707d83d
--- /dev/null
@@ -0,0 +1,69 @@
+<script lang="ts">
+       import { XCircle, Terminal } from '@lucide/svelte';
+       import { SyntaxHighlightedCode } from '$lib/components/app';
+       import { FileTypeText } from '$lib/enums';
+       import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
+       import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
+       import { parseRunJavascriptMeta } from './parsers/run-javascript';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const runJsMeta = $derived(parseRunJavascriptMeta(section));
+       const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
+       {#snippet children(meta, ctx)}
+               {#if ctx.isPending}
+                       <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
+               {:else if meta?.errorMessage}
+                       <div
+                               class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+                       >
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{meta.errorMessage}</span>
+                       </div>
+                       <div class="mt-3">
+                               <SyntaxHighlightedCode
+                                       code={meta.code}
+                                       language={FileTypeText.JAVASCRIPT}
+                                       maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                                       streaming={ctx.isCodeStreaming}
+                               />
+                       </div>
+               {:else if meta}
+                       <SyntaxHighlightedCode
+                               code={meta.code}
+                               language={FileTypeText.JAVASCRIPT}
+                               maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                               streaming={ctx.isCodeStreaming}
+                       />
+                       <div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
+                               <Terminal class="h-3 w-3" />
+                               <span>Console</span>
+                               {#if meta.timeoutMs != null}
+                                       <span class="font-mono">&middot;&nbsp;timeout&nbsp;{meta.timeoutMs}&nbsp;ms</span>
+                               {/if}
+                       </div>
+                       {#if section.toolResult}
+                               <div class="mt-1">
+                                       <SyntaxHighlightedCode
+                                               code={section.toolResult}
+                                               language={FileTypeText.JAVASCRIPT}
+                                               maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                                       />
+                               </div>
+                       {:else}
+                               <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
+                       {/if}
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..e4b4adf
--- /dev/null
@@ -0,0 +1,167 @@
+<script lang="ts">
+       import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
+       import { Globe, Loader2 } from '@lucide/svelte';
+       import { CollapsibleContentBlock } from '$lib/components/app';
+       import * as HoverCard from '$lib/components/ui/hover-card';
+       import { AgenticSectionType } from '$lib/enums';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import {
+               extractSearchResults,
+               extractSearchQuery,
+               faviconForUrl,
+               sanitizeExternalUrl,
+               type SearchResult,
+               type AgenticSection
+       } from '$lib/utils';
+
+       interface Props {
+               section: AgenticSection;
+               open?: boolean;
+               isStreaming?: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props();
+
+       const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
+       const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
+       const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
+
+       const results: SearchResult[] = $derived(extractSearchResults(section.toolResult));
+       const query = $derived(extractSearchQuery(section.toolArgs));
+
+       // Same icon-resolution chain as ChatMessageToolCallBlockDefault so
+       // MCP-server branding is consistent across both views. Spinner wins
+       // while the call is in flight so the user sees execution status.
+       const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
+       const icon = $derived(showSpinner ? Loader2 : undefined);
+       const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
+
+       // Verb reflects state: "Searching" while the call is in flight, "Searched"
+       // once results (or a definitive empty response) have arrived. Lets the
+       // heading read as a live progress indicator rather than a completed
+       // retrospective.
+       const title = $derived.by(() => {
+               const verb = showSpinner ? 'Searching' : 'Searched';
+               return query ? `${verb} web for "${query}"` : `${verb} web`;
+       });
+
+       function hideBrokenIcon(event: Event) {
+               (event.currentTarget as HTMLImageElement).style.display = 'none';
+       }
+
+       function formatPublishDate(iso: string | undefined): string | null {
+               if (!iso) return null;
+               try {
+                       const date = new Date(iso);
+                       if (Number.isNaN(date.getTime())) return iso;
+                       return date.toLocaleDateString(undefined, {
+                               year: 'numeric',
+                               month: 'short',
+                               day: 'numeric'
+                       });
+               } catch {
+                       return iso;
+               }
+       }
+
+       function hostFor(url: string): string | null {
+               try {
+                       return new URL(url).host;
+               } catch {
+                       return null;
+               }
+       }
+
+       function hasDetails(result: SearchResult): boolean {
+               return Boolean(result.highlights || result.published || result.author);
+       }
+</script>
+
+{#snippet pill(result: SearchResult)}
+       {@const faviconUrl = faviconForUrl(result.url)}
+       {@const safeUrl = sanitizeExternalUrl(result.url)}
+       {@const showHoverCard = safeUrl !== null && hasDetails(result)}
+       {#if safeUrl}
+               <HoverCard.Root openDelay={150} closeDelay={100}>
+                       <HoverCard.Trigger
+                               href={safeUrl}
+                               target="_blank"
+                               rel="noopener noreferrer"
+                               class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
+                       >
+                               {#if faviconUrl}
+                                       <img
+                                               src={faviconUrl}
+                                               alt=""
+                                               class="h-3 w-3 shrink-0 rounded-sm"
+                                               onerror={hideBrokenIcon}
+                                       />
+                               {:else}
+                                       <Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
+                               {/if}
+                               <span class="truncate font-medium text-foreground/80">{result.title}</span>
+                       </HoverCard.Trigger>
+                       {#if showHoverCard}
+                               {@const publishDate = formatPublishDate(result.published)}
+                               {@const host = hostFor(safeUrl)}
+                               <HoverCard.Content
+                                       side="top"
+                                       align="start"
+                                       sideOffset={6}
+                                       class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
+                               >
+                                       <div class="flex flex-col gap-2 p-3">
+                                               <a
+                                                       href={safeUrl}
+                                                       target="_blank"
+                                                       rel="noopener noreferrer"
+                                                       class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
+                                                       >{result.title}</a
+                                               >
+                                               {#if publishDate || result.author}
+                                                       <div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
+                                                               {#if publishDate}
+                                                                       <span>{publishDate}</span>
+                                                               {/if}
+                                                               {#if publishDate && result.author}
+                                                                       <span class="opacity-50">&middot;</span>
+                                                               {/if}
+                                                               {#if result.author}
+                                                                       <span class="truncate">{result.author}</span>
+                                                               {/if}
+                                                       </div>
+                                               {/if}
+                                               {#if result.highlights}
+                                                       <p
+                                                               class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
+                                                       >
+                                                               {result.highlights}
+                                                       </p>
+                                               {/if}
+                                               {#if host}
+                                                       <div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
+                                               {/if}
+                                       </div>
+                               </HoverCard.Content>
+                       {/if}
+               </HoverCard.Root>
+       {/if}
+{/snippet}
+
+<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
+       {#if results.length > 0}
+               <div class="flex flex-wrap items-center gap-2 pb-1">
+                       {#each results as result (result.url)}
+                               {@render pill(result)}
+                       {/each}
+               </div>
+       {:else if showSpinner}
+               <div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
+                       <Loader2 class="h-3 w-3 animate-spin" />
+                       <span>Searching...</span>
+               </div>
+       {:else}
+               <div class="text-muted-foreground/70 py-1 text-xs italic">No results</div>
+       {/if}
+</CollapsibleContentBlock>
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 (file)
index 0000000..eda0676
--- /dev/null
@@ -0,0 +1,55 @@
+<script lang="ts">
+       import { XCircle } from '@lucide/svelte';
+       import { SyntaxHighlightedCode } from '$lib/components/app';
+       import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
+       import { type AgenticSection } from '$lib/utils';
+       import { parseWriteFileMeta } from './parsers/write-file';
+       import ToolCallBlock from './ToolCallBlock.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               onToggle?: () => void;
+       }
+
+       let { section, open, isStreaming, onToggle }: Props = $props();
+
+       const writeFileMeta = $derived(parseWriteFileMeta(section));
+</script>
+
+<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
+       {#snippet titleSnippet()}
+               <span class="text-muted-foreground">Write file </span>
+               <span class="font-mono">{writeFileMeta?.filePath}</span>
+               {#if writeFileMeta?.errorMessage}
+                       <span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
+               {/if}
+       {/snippet}
+
+       {#snippet children(meta, ctx)}
+               {#if meta?.errorMessage}
+                       <div
+                               class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
+                       >
+                               <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
+                               <span>{meta.errorMessage}</span>
+                       </div>
+               {:else if meta}
+                       <SyntaxHighlightedCode
+                               code={meta.content}
+                               language={meta.language}
+                               maxHeight={MAX_HEIGHT_CODE_BLOCK}
+                               streaming={ctx.isCodeStreaming}
+                       />
+                       <div class="mt-1.5 text-xs text-muted-foreground/70 italic">
+                               {#if meta.resultMessage}
+                                       {meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
+                               {#if meta.bytesWritten != null}
+                                       <span class="font-mono">{meta.bytesWritten}</span>
+                                       bytes
+                               {/if}
+                       </div>
+               {/if}
+       {/snippet}
+</ToolCallBlock>
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 (file)
index 0000000..a17a74e
--- /dev/null
@@ -0,0 +1,129 @@
+<script lang="ts" generics="TMeta">
+       // Generic chrome shell shared by every per-tool block under
+       // `ChatMessageToolCall/`. Owns:
+       //   - the collapsible wrapper (defaults to CollapsibleContentBlock;
+       //     `exec_shell_command` swaps in CollapsibleTerminalBlock via the
+       //     `wrapper` prop);
+       //   - the icon, spinner state, and MCP favicon fallback chain;
+       //   - the status subtitle pill.
+       // Components supply only their `meta`, a title snippet, and a body
+       // snippet - everything around them is this single source of truth.
+
+       import { Loader2, Wrench } from '@lucide/svelte';
+       import { CollapsibleContentBlock } from '$lib/components/app';
+       import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
+       import { AgenticSectionType } from '$lib/enums';
+       import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
+       import { mcpStore } from '$lib/stores/mcp.svelte';
+       import type { Component, Snippet } from 'svelte';
+       import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
+
+       type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
+
+       interface ToolCallCtx {
+               isStreaming: boolean;
+               isPending: boolean;
+               isStreamingCall: boolean;
+               isCodeStreaming: boolean;
+       }
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               /**
+                * The per-tool meta, including any `errorMessage` field that the
+                * shared chrome uses to compute the status pill subtitle.
+                */
+               meta: ToolCallBlockMetaWithError | null | undefined;
+               /**
+                * True while the tool's process is actively producing output
+                * chunks after its args finished streaming (used by
+                * `exec_shell_command`'s stdout feed).
+                */
+               extraLiveStreaming?: boolean;
+               /**
+                * Swap the title-row icon for a spinning `Loader2` while the
+                * spinner is showing. Only meaningful for tools where "live"
+                * is interesting (e.g. exec_shell_command showing the in-flight
+                * process). Other tools leave it off and render the spinner
+                * inline within the body.
+                */
+               spinIconWhenActive?: boolean;
+               /**
+                * Wrapper component that renders the title row and the body
+                * children. Defaults to CollapsibleContentBlock;
+                * `exec_shell_command` uses CollapsibleTerminalBlock for its
+                * terminal-style frame.
+                */
+               wrapper?: typeof CollapsibleContentBlock;
+               title?: string;
+               titleSnippet?: Snippet;
+               onToggle?: () => void;
+               children: Snippet<[TMeta | null | undefined, ToolCallCtx]>;
+       }
+
+       let {
+               section,
+               open,
+               isStreaming,
+               meta,
+               extraLiveStreaming = false,
+               spinIconWhenActive = false,
+               wrapper: Wrapper = CollapsibleContentBlock,
+               title,
+               titleSnippet,
+               onToggle,
+               children
+       }: Props = $props();
+
+       const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
+       const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
+       const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
+       const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
+
+       const toolUi: BuiltinToolUiEntry | null = $derived(getBuiltinToolUi(section.toolName));
+       const toolIcon: Component = $derived(
+               spinIconWhenActive && showSpinner ? Loader2 : (toolUi?.icon ?? Wrench)
+       );
+       const toolIconClass = $derived(
+               spinIconWhenActive && showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT
+       );
+       // Drop the MCP favicon while the spinner is on so the title row
+       // signals "in flight" without being overwritten by server branding.
+       const mcpServerFavicon = $derived(
+               showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName)
+       );
+       const iconUrl = $derived(
+               showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
+       );
+
+       function subtitleFor(errorMessage?: string): string | undefined {
+               if (extraLiveStreaming) return 'streaming...';
+               if (showSpinner) return 'executing...';
+               if (errorMessage) return 'failed';
+               if (isStreamingCall && !isStreaming) return 'incomplete';
+               return undefined;
+       }
+
+       const subtitle = $derived(subtitleFor(meta?.errorMessage));
+</script>
+
+<Wrapper
+       {open}
+       class="my-2"
+       icon={toolIcon}
+       iconClass={toolIconClass}
+       {iconUrl}
+       {title}
+       {titleSnippet}
+       {subtitle}
+       {onToggle}
+>
+       {@render children(meta, {
+               isStreaming,
+               isPending,
+               isStreamingCall,
+               isCodeStreaming
+       })}
+</Wrapper>
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 (file)
index 0000000..6114f17
--- /dev/null
@@ -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<string, unknown> | null {
+       try {
+               const parsed: unknown = JSON.parse(blob);
+               if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                       return parsed as Record<string, unknown>;
+               }
+               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<string, unknown> | 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 (file)
index 0000000..4bff25b
--- /dev/null
@@ -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<string, unknown>;
+               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 (file)
index 0000000..e8adbd1
--- /dev/null
@@ -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 (file)
index 0000000..1ad92b7
--- /dev/null
@@ -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<string, unknown>;
+                               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 (file)
index 0000000..0e606e1
--- /dev/null
@@ -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<string, unknown>;
+                               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 `<file>:<content>`
+                       // (or `<file>:<line>:<content>`) 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:
+       //   <file>:<content>          when return_line_numbers=false
+       //   <file>:<lineno>:<content> 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 (file)
index 0000000..d37dcf5
--- /dev/null
@@ -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 (file)
index 0000000..9bcba8f
--- /dev/null
@@ -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<string, unknown> | null = null;
+               try {
+                       const parsed: unknown = JSON.parse(toolResultString);
+                       if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                               parsedObject = parsed as Record<string, unknown>;
+                       }
+               } 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 (file)
index 0000000..95edc3d
--- /dev/null
@@ -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
+       };
+}
index 254031979d44922dd12f8cc82340cefce737c19f..17d8e21d7b1446421d5693d9cacd19d36616c2b1 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import type { Snippet, Component } from 'svelte';
 
        interface Props {
@@ -12,7 +13,7 @@
 
 <div class="my-2 rounded-lg border border-border bg-card p-3">
        <div class="mb-3 flex items-center gap-2 text-sm">
-               <IconComponent class="h-4 w-4 shrink-0 text-muted-foreground" />
+               <IconComponent class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
                <span>
                        {@render message()}
                </span>
index f0d03f547862d5586573f1aae732a42f33b270c3..34c82cd76328c00ce4a939e21b7e8ea8d1baf68d 100644 (file)
@@ -1,40 +1,29 @@
 <script lang="ts">
-       import { Wrench, Loader2, Brain } from '@lucide/svelte';
        import {
                ChatMessageStatistics,
-               CollapsibleContentBlock,
                MarkdownContent,
-               SyntaxHighlightedCode,
                ChatMessageActionCardPermissionRequest,
                ChatMessageActionCardContinueRequest
        } from '$lib/components/app';
 
-       import {
-               AgenticSectionType,
-               ChatMessageStatsView,
-               FileTypeText,
-               ToolPermissionDecision
-       } from '$lib/enums';
+       import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
        import type {
                ChatMessageAgenticTimings,
                ChatMessageAgenticTurnStats,
                DatabaseMessage
        } from '$lib/types';
-       import {
-               deriveAgenticSections,
-               formatJsonPretty,
-               parseToolResultWithImages,
-               type AgenticSection,
-               type ToolResultLine
-       } from '$lib/utils';
+       import { deriveAgenticSections, type AgenticSection } from '$lib/utils';
        import {
                agenticPendingPermissionRequest,
                agenticResolvePermission,
                agenticPendingContinueRequest,
                agenticResolveContinue,
-               agenticLastError
+               agenticLastError,
+               agenticExecutingToolCallId
        } from '$lib/stores/agentic.svelte';
        import { config } from '$lib/stores/settings.svelte';
+       import ChatMessageReasoningBlock from './ChatMessageReasoningBlock.svelte';
+       import ChatMessageToolCallBlock from './ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte';
 
        interface Props {
                message: DatabaseMessage;
 
        let expandedStates: Record<number, boolean> = $state({});
 
-       const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
-       const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
        const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
-       const showMessageStats = $derived(config().showMessageStats as boolean);
+       const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
+       const showMessageStats = $derived(Boolean(config().showMessageStats));
+       const showAgenticTurnStats = $derived(showMessageStats && Boolean(config().showAgenticTurnStats));
 
        const hasReasoningError = $derived(
                isLastAssistantMessage ? !!agenticLastError(message.convId) : false
@@ -67,7 +56,6 @@
                isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
        );
 
-       // Reset dismissed when pendingPermission changes (new request or cleared)
        let prevPendingRef: typeof pendingPermission = null;
        $effect(() => {
                if (pendingPermission !== prevPendingRef) {
 
        const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
 
-       // Parse tool results with images
-       const sectionsParsed = $derived(
-               sections.map((section) => ({
-                       ...section,
-                       parsedLines: section.toolResult
-                               ? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
-                               : ([] as ToolResultLine[])
-               }))
+       const currentlyExecutingToolCallId = $derived(
+               isStreaming ? agenticExecutingToolCallId(message.convId) : null
        );
 
-       // Group flat sections into agentic turns
-       // A new turn starts when a non-tool section follows a tool section
-       const turnGroups = $derived.by(() => {
-               const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
-               let currentTurn: (typeof sectionsParsed)[number][] = [];
+       // Skip sections the user manually collapsed - we never override an explicit false.
+       let lastSeenExecutingToolCallId: string | null = null;
+       $effect(() => {
+               const current = currentlyExecutingToolCallId;
+               const previous = lastSeenExecutingToolCallId;
+               lastSeenExecutingToolCallId = current;
+               if (!current || current === previous) return;
+               const idx = sections.findIndex((s) => s.toolCallId === current);
+               if (idx >= 0 && expandedStates[idx] === undefined) {
+                       expandedStates[idx] = true;
+               }
+       });
+
+       type TurnGroup = {
+               sections: AgenticSection[];
+               flatIndices: number[];
+       };
+
+       const turnGroups: TurnGroup[] = $derived.by(() => {
+               const groups: TurnGroup[] = [];
+               let currentTurn: AgenticSection[] = [];
                let currentIndices: number[] = [];
                let prevWasTool = false;
 
-               for (let i = 0; i < sectionsParsed.length; i++) {
-                       const section = sectionsParsed[i];
+               for (let i = 0; i < sections.length; i++) {
+                       const section = sections[i];
                        const isTool =
                                section.type === AgenticSectionType.TOOL_CALL ||
                                section.type === AgenticSectionType.TOOL_CALL_PENDING ||
                                section.type === AgenticSectionType.TOOL_CALL_STREAMING;
 
                        if (!isTool && prevWasTool && currentTurn.length > 0) {
-                               turns.push({ sections: currentTurn, flatIndices: currentIndices });
+                               groups.push({ sections: currentTurn, flatIndices: currentIndices });
                                currentTurn = [];
                                currentIndices = [];
                        }
                }
 
                if (currentTurn.length > 0) {
-                       turns.push({ sections: currentTurn, flatIndices: currentIndices });
+                       groups.push({ sections: currentTurn, flatIndices: currentIndices });
                }
 
-               return turns;
+               return groups;
        });
 
        function getDefaultExpanded(section: AgenticSection): boolean {
                        section.type === AgenticSectionType.TOOL_CALL_PENDING ||
                        section.type === AgenticSectionType.TOOL_CALL_STREAMING
                ) {
-                       return showToolCallInProgress;
+                       return false;
                }
 
                if (section.type === AgenticSectionType.REASONING_PENDING) {
        }
 </script>
 
-{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
+{#snippet renderSection(section: AgenticSection, index: number)}
        {#if section.type === AgenticSectionType.TEXT}
                <div class="agentic-text">
                        <MarkdownContent content={section.content} attachments={message?.extra} />
                </div>
-       {: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'}
-
-               <CollapsibleContentBlock
+       {:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
+               <ChatMessageReasoningBlock
+                       {section}
                        open={isExpanded(index, section)}
-                       class="my-2"
-                       icon={streamingIcon}
-                       iconClass={streamingIconClass}
-                       title={section.toolName || 'Tool call'}
-                       subtitle={isStreaming ? '' : 'incomplete'}
                        {isStreaming}
+                       {renderThinkingAsMarkdown}
+                       {hasReasoningError}
+                       attachments={message?.extra}
                        onToggle={() => toggleExpanded(index, section)}
-               >
-                       <div class="pt-3">
-                               <div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
-                                       <span>Arguments:</span>
-
-                                       {#if isStreaming}
-                                               <Loader2 class="h-3 w-3 animate-spin" />
-                                       {/if}
-                               </div>
-                               {#if section.toolArgs}
-                                       <SyntaxHighlightedCode
-                                               code={formatJsonPretty(section.toolArgs)}
-                                               language={FileTypeText.JSON}
-                                               maxHeight="20rem"
-                                               class="text-xs"
-                                       />
-                               {:else if isStreaming}
-                                       <div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
-                                               Receiving arguments...
-                                       </div>
-                               {:else}
-                                       <div
-                                               class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
-                                       >
-                                               Response was truncated
-                                       </div>
-                               {/if}
-                       </div>
-               </CollapsibleContentBlock>
-       {: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'}
-
-               <CollapsibleContentBlock
-                       open={isExpanded(index, section)}
-                       class="my-2"
-                       icon={toolIcon}
-                       iconClass={toolIconClass}
-                       title={section.toolName || ''}
-                       subtitle={isPending ? 'executing...' : undefined}
-                       isStreaming={isPending}
-                       onToggle={() => toggleExpanded(index, section)}
-               >
-                       {#if section.toolArgs && section.toolArgs !== '{}'}
-                               <div class="pt-3">
-                                       <div class="my-3 text-xs text-muted-foreground">Arguments:</div>
-
-                                       <SyntaxHighlightedCode
-                                               code={formatJsonPretty(section.toolArgs)}
-                                               language={FileTypeText.JSON}
-                                               maxHeight="20rem"
-                                               class="text-xs"
-                                       />
-                               </div>
-                       {/if}
-
-                       <div class="pt-3">
-                               <div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
-                                       <span>Result:</span>
-
-                                       {#if isPending}
-                                               <Loader2 class="h-3 w-3 animate-spin" />
-                                       {/if}
-                               </div>
-                               {#if isPending}
-                                       <div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
-                                               Waiting for result...
-                                       </div>
-                               {:else if section.toolResult}
-                                       <div class="overflow-auto rounded-lg border border-border bg-muted p-4">
-                                               {#each section.parsedLines as line, i (i)}
-                                                       <div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">
-                                                               {line.text}
-                                                       </div>
-                                                       {#if line.image}
-                                                               <img
-                                                                       src={line.image.base64Url}
-                                                                       alt={line.image.name}
-                                                                       class="mt-2 mb-2 h-auto max-w-full rounded-lg"
-                                                                       loading="lazy"
-                                                               />
-                                                       {/if}
-                                               {/each}
-                                       </div>
-                               {:else}
-                                       <div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">No output</div>
-                               {/if}
-                       </div>
-               </CollapsibleContentBlock>
-       {:else if section.type === AgenticSectionType.REASONING}
-               {@const reasoningSubtitle = section.wasInterrupted
-                       ? hasReasoningError
-                               ? 'Error'
-                               : 'Cancelled'
-                       : isStreaming
-                               ? ''
-                               : undefined}
-
-               <CollapsibleContentBlock
-                       open={isExpanded(index, section)}
-                       class="my-2"
-                       icon={Brain}
-                       title="Reasoning"
-                       subtitle={reasoningSubtitle}
-                       rawContent={section.content}
-                       onToggle={() => toggleExpanded(index, section)}
-               >
-                       <div class="pt-3">
-                               {#if renderThinkingAsMarkdown}
-                                       <MarkdownContent content={section.content} attachments={message?.extra} />
-                               {:else}
-                                       <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
-                                               {section.content}
-                                       </div>
-                               {/if}
-                       </div>
-               </CollapsibleContentBlock>
-       {:else if section.type === AgenticSectionType.REASONING_PENDING}
-               {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
-               {@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
-
-               <CollapsibleContentBlock
+               />
+       {:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
+               <ChatMessageToolCallBlock
+                       {section}
                        open={isExpanded(index, section)}
-                       class="my-2"
-                       icon={Brain}
-                       title={reasoningTitle}
-                       subtitle={reasoningSubtitle}
-                       rawContent={section.content}
                        {isStreaming}
+                       isExecuting={section.toolCallId !== undefined &&
+                               section.toolCallId === currentlyExecutingToolCallId}
+                       attachments={message?.extra}
                        onToggle={() => toggleExpanded(index, section)}
-               >
-                       <div class="pt-3">
-                               {#if renderThinkingAsMarkdown}
-                                       <MarkdownContent content={section.content} attachments={message?.extra} />
-                               {:else}
-                                       <div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
-                                               {section.content}
-                                       </div>
-                               {/if}
-                       </div>
-               </CollapsibleContentBlock>
+               />
        {/if}
 {/snippet}
 
-<div class="agentic-content">
+<div class="agentic-content gap-2">
        {#if turnGroups.length > 1}
                {#each turnGroups as turn, turnIndex (turnIndex)}
                        {@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
 
-                       <div class="agentic-turn group/turn grid gap-3 mb-4">
+                       <div class="agentic-turn group/turn grid gap-2">
                                {#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
                                        {@render renderSection(section, turn.flatIndices[sIdx])}
                                {/each}
 
-                               {#if turnStats && showMessageStats}
-                                       <div class="turn-stats transition-opacity duration-150">
+                               {#if turnStats && showAgenticTurnStats}
+                                       <div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
                                                <ChatMessageStatistics
                                                        promptTokens={turnStats.llm.prompt_n}
                                                        promptMs={turnStats.llm.prompt_ms}
                        </div>
                {/each}
        {:else}
-               {#each sectionsParsed as section, index (index)}
+               {#each sections as section, index (index)}
                        {@render renderSection(section, index)}
                {/each}
        {/if}
                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 (file)
index 0000000..833cae5
--- /dev/null
@@ -0,0 +1,151 @@
+<script lang="ts">
+       import { Lightbulb } from '@lucide/svelte';
+       import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
+       import { AgenticSectionType } from '$lib/enums';
+       import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+       import type { DatabaseMessageExtra } from '$lib/types';
+       import type { AgenticSection } from '$lib/utils';
+
+       interface Props {
+               section: AgenticSection;
+               open: boolean;
+               isStreaming: boolean;
+               renderThinkingAsMarkdown: boolean;
+               hasReasoningError?: boolean;
+               attachments?: DatabaseMessageExtra[];
+               onToggle?: () => void;
+       }
+
+       let {
+               section,
+               open,
+               isStreaming,
+               renderThinkingAsMarkdown,
+               hasReasoningError = false,
+               attachments,
+               onToggle
+       }: Props = $props();
+
+       const REASONING_HEADER = 'Reasoning';
+       const REASONING_HEADER_PENDING = 'Reasoning...';
+       const REASONING_SUBTITLE_ERROR = 'Error';
+       const REASONING_SUBTITLE_CANCELLED = 'Cancelled';
+
+       const isPending = $derived(section.type === AgenticSectionType.REASONING_PENDING);
+       const title = $derived(isPending && isStreaming ? REASONING_HEADER_PENDING : REASONING_HEADER);
+       const subtitle = $derived.by(() => {
+               if (isPending && !isStreaming) {
+                       return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
+               }
+               if (section.wasInterrupted) {
+                       return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
+               }
+               return isStreaming ? '' : undefined;
+       });
+       const shimmerTitle = $derived(isPending && isStreaming);
+
+       let scrollEl: HTMLDivElement | undefined = $state();
+
+       const SCROLL_BOTTOM_THRESHOLD_PX = REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX;
+
+       let userScrolledUp = $state(false);
+       let lastScrollTop = 0;
+       let pendingFrame: number | null = null;
+
+       function isAtBottom(): boolean {
+               if (!scrollEl) return false;
+               return (
+                       scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
+                       SCROLL_BOTTOM_THRESHOLD_PX
+               );
+       }
+
+       function scrollToBottomOnFrame() {
+               if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+               pendingFrame = requestAnimationFrame(() => {
+                       pendingFrame = null;
+                       // User may scroll between scheduling and paint.
+                       if (scrollEl && !userScrolledUp) {
+                               scrollEl.scrollTop = scrollEl.scrollHeight;
+                       }
+               });
+       }
+
+       function handleScrollEvent() {
+               if (!scrollEl) return;
+               const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+               if (isScrollingUp && !isAtBottom()) {
+                       userScrolledUp = true;
+               } else if (isAtBottom()) {
+                       userScrolledUp = false;
+               }
+               lastScrollTop = scrollEl.scrollTop;
+       }
+
+       $effect(() => {
+               void section.content;
+               if (!scrollEl || !isPending || !isStreaming) return;
+               scrollToBottomOnFrame();
+       });
+
+       $effect(() => {
+               // Layout shifts that don't change section.content (markdown re-parse,
+               // syntax-highlight settle, image loads).
+               if (!scrollEl || !isPending || !isStreaming) return;
+
+               const observer = new MutationObserver(() => scrollToBottomOnFrame());
+               observer.observe(scrollEl, {
+                       childList: true,
+                       subtree: true,
+                       characterData: true
+               });
+
+               return () => observer.disconnect();
+       });
+
+       $effect(() => {
+               // Pin to bottom at the start of each round.
+               if (!isPending) {
+                       userScrolledUp = false;
+                       lastScrollTop = 0;
+               }
+       });
+</script>
+
+<CollapsibleContentBlock
+       {open}
+       class="my-2"
+       icon={Lightbulb}
+       iconClass="h-3.5 w-3.5"
+       {title}
+       {subtitle}
+       {shimmerTitle}
+       {onToggle}
+>
+       <div
+               bind:this={scrollEl}
+               class="reasoning-content"
+               class:is-streaming={isPending}
+               onscroll={handleScrollEvent}
+       >
+               {#if renderThinkingAsMarkdown}
+                       <MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
+               {:else}
+                       <div
+                               class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
+                       >
+                               {section.content}
+                       </div>
+               {/if}
+       </div>
+</CollapsibleContentBlock>
+
+<style>
+       .reasoning-content.is-streaming {
+               max-height: 28rem;
+               overflow-y: auto;
+               overscroll-behavior: contain;
+               scrollbar-gutter: stable;
+               padding-right: 0.25rem;
+       }
+</style>
index c470ac729690ceb93f95a963b00284fd79fb589c..dca24afd440fe5b8c1fe069bee92c1d84c6344d7 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { ArrowDown } from '@lucide/svelte';
        import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
 
@@ -12,7 +13,7 @@
                ariaLabel="Scroll to bottom"
                tooltip="Scroll to bottom"
                size="lg"
-               iconSize="h-4 w-4"
+               iconSize={ICON_CLASS_DEFAULT}
                class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
        />
 </div>
index 5345f19a57be7b654a5e3116e769597e0444fcdc..713c3b461d49eac4f331c1e65a5825684aa53bf6 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
        import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import * as Alert from '$lib/components/ui/alert';
@@ -15,9 +16,9 @@
        >
                <Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
                        {#if isLoadingModel}
-                               <Loader2 class="h-4 w-4 animate-spin" />
+                               <Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
                        {:else}
-                               <AlertTriangle class="h-4 w-4" />
+                               <AlertTriangle class={ICON_CLASS_DEFAULT} />
                        {/if}
 
                        <Alert.Title class="flex items-center justify-between">
index 4f826841e4b79aadb78dcc091d8515fbef2448cb..cd06ec0366197e8786b33a603aa566c66696a34e 100644 (file)
@@ -571,6 +571,10 @@ export { default as ChatMessageMcpPromptContent } from './ChatMessages/ChatMessa
  * Handles streaming state with real-time content updates.
  */
 export { default as ChatMessageAssistant } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte';
+export { default as ChatMessageAssistantModel } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantModel.svelte';
+export { default as ChatMessageAssistantProcessingInfo } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantProcessingInfo.svelte';
+export { default as ChatMessageAssistantRawOutput } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantRawOutput.svelte';
+export { default as ChatMessageAssistantStatistics } from './ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistantStatistics.svelte';
 
 /**
  * Inline message editing form. Provides textarea for editing message content with
index 3875b449a186147731a8c200b678ba75cc2302d8..ea7fe7b952942b067d0e856a9485367c9c59aee4 100644 (file)
@@ -1,12 +1,8 @@
 <script lang="ts">
-       import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
+       import ChevronDown from '@lucide/svelte/icons/chevron-down';
        import * as Collapsible from '$lib/components/ui/collapsible/index.js';
-       import { buttonVariants } from '$lib/components/ui/button/index.js';
-       import { Card } from '$lib/components/ui/card';
-       import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
-       import { useThrottle } from '$lib/hooks/use-throttle.svelte';
-       import { formatReasoningPreview } from '$lib/utils';
-       import { config } from '$lib/stores/settings.svelte';
+       import { cn } from '$lib/components/ui/utils';
        import type { Snippet } from 'svelte';
        import type { Component } from 'svelte';
 
                class?: string;
                icon?: Component;
                iconClass?: string;
-               title: string;
+               iconUrl?: string | null;
+               title?: string;
+               titleSnippet?: Snippet;
                subtitle?: string;
-               preview?: string;
-               rawContent?: string;
-               isStreaming?: boolean;
+               shimmerTitle?: boolean;
                onToggle?: () => void;
                children: Snippet;
        }
                open = $bindable(false),
                class: className = '',
                icon: IconComponent,
-               iconClass = 'h-4 w-4',
-               title,
+               iconClass = ICON_CLASS_DEFAULT,
+               iconUrl = null,
+               title = '',
+               titleSnippet,
                subtitle,
-               preview,
-               rawContent,
-               isStreaming = false,
+               shimmerTitle = false,
                onToggle,
                children
        }: Props = $props();
 
-       let contentContainer: HTMLDivElement | undefined = $state();
-
-       const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
-
-       let previewKey = useThrottle(() => rawContent ?? preview ?? '', 500);
-       let displayedPreview = $state('');
-       let displayedOverflow = $state(0);
-
-       $effect(() => {
-               void previewKey.key;
-               const content = rawContent ?? preview ?? '';
-               const result = formatReasoningPreview(content);
-               displayedPreview = result.preview;
-               displayedOverflow = result.overflow;
-       });
-
-       const autoScroll = createAutoScrollController();
-
-       $effect(() => {
-               autoScroll.setContainer(contentContainer);
-       });
-
-       $effect(() => {
-               // Only auto-scroll when open and streaming
-               autoScroll.updateInterval(open && isStreaming);
-       });
-
-       function handleScroll() {
-               autoScroll.handleScroll();
+       function hideBrokenIcon(event: Event) {
+               (event.currentTarget as HTMLImageElement).style.display = 'none';
        }
 </script>
 
                open = value;
                onToggle?.();
        }}
-       class="{className} my-0!"
+       class={cn('group/collapsible', 'my-0!', className)}
 >
-       <Card class="gap-0 border-muted bg-muted/30 py-0">
-               <Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
-                       <div class="flex min-w-0 items-center gap-2">
-                               <div class="flex items-center gap-2 text-muted-foreground">
-                                       {#if IconComponent}
-                                               <IconComponent class={iconClass} />
-                                       {/if}
-
-                                       <span class="font-mono text-sm font-medium">{title}</span>
-
-                                       {#if subtitle}
-                                               <span class="text-xs italic">{subtitle}</span>
-                                       {/if}
-                               </div>
-
-                               {#if displayedPreview && !showThoughtInProgress}
-                                       <div class="flex min-w-0 items-baseline justify-between gap-2">
-                                               <div class="w-3/4 truncate text-xs text-muted-foreground/80">
-                                                       {displayedPreview}
-                                               </div>
-                                               {#if displayedOverflow > 0}
-                                                       <span class="shrink-0 text-xs text-muted-foreground/60"
-                                                               >{displayedOverflow}+ chars</span
-                                                       >
-                                               {/if}
-                                       </div>
+       <Collapsible.Trigger
+               class={cn(
+                       'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
+                       'py-1.5 pr-1'
+               )}
+       >
+               <div class="flex min-w-0 items-start gap-2 text-muted-foreground">
+                       {#if iconUrl}
+                               <img
+                                       src={iconUrl}
+                                       alt=""
+                                       class={cn('shrink-0 rounded-sm  mt-0.75', iconClass)}
+                                       onerror={hideBrokenIcon}
+                               />
+                       {:else if IconComponent}
+                               <IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.75', iconClass)} />
+                       {/if}
+
+                       <span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
+                               {#if titleSnippet}
+                                       {@render titleSnippet()}
+                               {:else}
+                                       {title}
                                {/if}
-                       </div>
-
-                       <div
-                               class={buttonVariants({
-                                       variant: 'ghost',
-                                       size: 'sm',
-                                       class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
-                               })}
-                       >
-                               <ChevronsUpDownIcon class="h-4 w-4" />
-
-                               <span class="sr-only">Toggle content</span>
-                       </div>
-               </Collapsible.Trigger>
-
-               <Collapsible.Content>
-                       <div
-                               bind:this={contentContainer}
-                               class="overflow-y-auto border-t border-muted px-3 pb-3"
-                               onscroll={handleScroll}
-                               style="min-height: var(--min-message-height); max-height: var(--max-message-height);"
-                       >
+                       </span>
+
+                       {#if subtitle}
+                               <span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
+                       {/if}
+               </div>
+
+               <ChevronDown
+                       class={cn(
+                               'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.75',
+                               open && 'rotate-180'
+                       )}
+               />
+
+               <span class="sr-only">Toggle content</span>
+       </Collapsible.Trigger>
+
+       <Collapsible.Content>
+               <div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
+                       <div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
                                {@render children()}
                        </div>
-               </Collapsible.Content>
-       </Card>
+               </div>
+       </Collapsible.Content>
 </Collapsible.Root>
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 (file)
index 0000000..4370aea
--- /dev/null
@@ -0,0 +1,97 @@
+<script lang="ts">
+       import ChevronDown from '@lucide/svelte/icons/chevron-down';
+       import * as Collapsible from '$lib/components/ui/collapsible/index.js';
+       import { cn } from '$lib/components/ui/utils';
+       import { ICON_CLASS_DEFAULT } from '$lib/constants';
+       import type { Snippet } from 'svelte';
+       import type { Component } from 'svelte';
+
+       interface Props {
+               open?: boolean;
+               class?: string;
+               icon?: Component;
+               iconClass?: string;
+               iconUrl?: string | null;
+               title?: string;
+               titleSnippet?: Snippet;
+               subtitle?: string;
+               shimmerTitle?: boolean;
+               onToggle?: () => void;
+               children: Snippet;
+       }
+
+       let {
+               open = $bindable(false),
+               class: className = '',
+               icon: IconComponent,
+               iconClass = ICON_CLASS_DEFAULT,
+               iconUrl = null,
+               title = '',
+               titleSnippet,
+               subtitle,
+               shimmerTitle = false,
+               onToggle,
+               children
+       }: Props = $props();
+
+       function hideBrokenIcon(event: Event) {
+               (event.currentTarget as HTMLImageElement).style.display = 'none';
+       }
+</script>
+
+<Collapsible.Root
+       {open}
+       onOpenChange={(value) => {
+               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);"
+>
+       <Collapsible.Trigger
+               class={cn(
+                       'flex w-full cursor-pointer items-start justify-between gap-2 text-left',
+                       'px-3 py-2'
+               )}
+       >
+               <div class="flex min-w-0 items-start gap-2 text-muted-foreground">
+                       {#if iconUrl}
+                               <img
+                                       src={iconUrl}
+                                       alt=""
+                                       class={cn('shrink-0 rounded-sm mt-0.5', iconClass)}
+                                       onerror={hideBrokenIcon}
+                               />
+                       {:else if IconComponent}
+                               <IconComponent class={cn('shrink-0 text-muted-foreground/60 mt-0.5', iconClass)} />
+                       {/if}
+
+                       <span class={cn('text-sm font-medium', shimmerTitle ? 'shimmer-text' : 'text-foreground/80')}>
+                               {#if titleSnippet}
+                                       {@render titleSnippet()}
+                               {:else}
+                                       {title}
+                               {/if}
+                       </span>
+
+                       {#if subtitle}
+                               <span class="text-xs italic text-muted-foreground/70">{subtitle}</span>
+                       {/if}
+               </div>
+
+               <ChevronDown
+                       class={cn(
+                               'size-4 shrink-0 text-muted-foreground/60 transition-all duration-150 ease-out opacity-0 group-hover/collapsible:opacity-100 mt-0.5',
+                               open && 'rotate-180'
+                       )}
+               />
+
+               <span class="sr-only">Toggle content</span>
+       </Collapsible.Trigger>
+
+       <Collapsible.Content>
+               <div class="p-3 pt-1">
+                       {@render children()}
+               </div>
+       </Collapsible.Content>
+</Collapsible.Root>
index b0e04ca6209a2af72f279588823677479d39a54b..41813f4fda765e0a2690688a58ff4f5fb8604eb5 100644 (file)
        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 */
index bb3185f40edac7ce13ac44172db415137e3df019..39540e7a8cd51a22fb4668066edcd72a489bb605 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Download } from '@lucide/svelte';
        import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
        import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
@@ -36,7 +37,7 @@
                        title="Zoom out"
                        aria-label="Zoom out"
                >
-                       <ZoomOutIcon class="mermaid-preview-btn-icon h-4 w-4" />
+                       <ZoomOutIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
                </button>
                <span
                        class="mermaid-preview-zoom-label min-w-[3.5rem] px-0.5 text-center text-xs font-medium text-muted-foreground tabular-nums select-none"
@@ -48,7 +49,7 @@
                        title="Zoom in"
                        aria-label="Zoom in"
                >
-                       <ZoomInIcon class="mermaid-preview-btn-icon h-4 w-4" />
+                       <ZoomInIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
                </button>
                <div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
 
@@ -58,7 +59,7 @@
                        title="Reset view"
                        aria-label="Reset view"
                >
-                       <RotateCcwIcon class="mermaid-preview-btn-icon h-4 w-4" />
+                       <RotateCcwIcon class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
                </button>
                <div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
 
@@ -68,7 +69,7 @@
                        title="Download SVG"
                        aria-label="Download SVG"
                >
-                       <Download class="mermaid-preview-btn-icon h-4 w-4" />
+                       <Download class="mermaid-preview-btn-icon {ICON_CLASS_DEFAULT}" />
                </button>
        </div>
 </div>
index d8dfe0ad9c3627c540c229d6175d6c670b68fe10..2d5725f559d2b58cd28612890286fa57aca6dfdc 100644 (file)
@@ -1,11 +1,12 @@
 <script lang="ts">
-       import hljs from 'highlight.js';
        import { browser } from '$app/environment';
        import { mode } from 'mode-watcher';
 
        import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import { ColorMode } from '$lib/enums';
+       import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
+       import { highlightCode } from '$lib/utils';
 
        interface Props {
                code: string;
@@ -13,6 +14,9 @@
                class?: string;
                maxHeight?: string;
                maxWidth?: string;
+               /** Auto-scrolls to the bottom of new chunks; pauses on user scroll-up
+                *  until the user returns to the bottom. */
+               streaming?: boolean;
        }
 
        let {
                language = 'text',
                class: className = '',
                maxHeight = '60vh',
-               maxWidth = ''
+               maxWidth = '',
+               streaming = false
        }: Props = $props();
 
-       let highlightedHtml = $state('');
+       const highlightedHtml = $derived(highlightCode(code, language));
+
+       let scrollEl = $state<HTMLDivElement>();
+       let userScrolledUp = $state(false);
+       let lastScrollTop = 0;
+       const SCROLL_BOTTOM_THRESHOLD_PX = SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX;
+       let pendingFrame: number | null = null;
 
        function loadHighlightTheme(isDark: boolean) {
                if (!browser) return;
                document.head.appendChild(style);
        }
 
+       function isAtBottom(): boolean {
+               if (!scrollEl) return false;
+               return (
+                       scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
+                       SCROLL_BOTTOM_THRESHOLD_PX
+               );
+       }
+
+       function scrollToBottomOnFrame() {
+               if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
+               pendingFrame = requestAnimationFrame(() => {
+                       pendingFrame = null;
+                       // User may scroll between scheduling and paint.
+                       if (scrollEl && !userScrolledUp) {
+                               scrollEl.scrollTop = scrollEl.scrollHeight;
+                       }
+               });
+       }
+
+       function handleScrollEvent() {
+               if (!scrollEl) return;
+               const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
+               if (isScrollingUp && !isAtBottom()) {
+                       userScrolledUp = true;
+               } else if (isAtBottom()) {
+                       userScrolledUp = false;
+               }
+               lastScrollTop = scrollEl.scrollTop;
+       }
+
        $effect(() => {
                const currentMode = mode.current;
                const isDark = currentMode === ColorMode.DARK;
                loadHighlightTheme(isDark);
        });
 
+       // Pin to bottom at the start of each streaming episode.
        $effect(() => {
-               if (!code) {
-                       highlightedHtml = '';
-                       return;
+               if (streaming) {
+                       userScrolledUp = false;
+                       lastScrollTop = 0;
                }
+       });
 
-               try {
-                       // Check if the language is supported
-                       const lang = language.toLowerCase();
-                       const isSupported = hljs.getLanguage(lang);
-
-                       if (isSupported) {
-                               const result = hljs.highlight(code, { language: lang });
-                               highlightedHtml = result.value;
-                       } else {
-                               // Try auto-detection or fallback to plain text
-                               const result = hljs.highlightAuto(code);
-                               highlightedHtml = result.value;
-                       }
-               } catch {
-                       // Fallback to escaped plain text
-                       highlightedHtml = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-               }
+       $effect(() => {
+               void code;
+               if (!streaming || userScrolledUp) return;
+               scrollToBottomOnFrame();
+       });
+
+       // Layout shifts that don't change `code` (highlight.js re-tokenize, line-wrap reflow).
+       $effect(() => {
+               if (!streaming || !scrollEl) return;
+
+               const observer = new MutationObserver(() => scrollToBottomOnFrame());
+               observer.observe(scrollEl, {
+                       childList: true,
+                       subtree: true,
+                       characterData: true
+               });
+
+               return () => observer.disconnect();
        });
 </script>
 
 <div
-       class="code-preview-wrapper min-w-0 max-w-full overflow-x-auto rounded-lg border border-border bg-muted {className}"
-       style="max-height: {maxHeight}; {maxWidth ? `max-width: ${maxWidth};` : ''}"
+       bind:this={scrollEl}
+       onscroll={handleScrollEvent}
+       class="code-preview-wrapper min-w-0 max-w-full overflow-auto rounded-xl border shadow-[0_1px_2px_0_rgb(0_0_0_/_0.05)] {className}"
+       style="border-color: color-mix(in oklch, var(--border) 30%, transparent); background: var(--code-background); max-height: {maxHeight}; {maxWidth
+               ? `max-width: ${maxWidth};`
+               : ''}"
 >
-       <!-- Needs to be formatted as single line for proper rendering -->
+       <!-- Single line: hljs injection depends on a contiguous source string. -->
        <pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
 </div>
 
 <style>
+       .code-preview-wrapper {
+               overscroll-behavior: contain;
+       }
+
        .code-preview-wrapper pre {
                background: transparent;
+               padding: 0;
        }
 
        .code-preview-wrapper code {
                background: transparent;
+               display: block;
+               padding: 0.5rem;
+       }
+
+       :global(.dark) .code-preview-wrapper {
+               border-color: color-mix(in oklch, var(--border) 20%, transparent);
        }
 </style>
index 5d2884bb214c84a60a00c27d9cdab5343687730b..5cfdd1b9c1e0bece0b0ed9fc16e735190c994ab0 100644 (file)
@@ -68,7 +68,6 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
  * ```svelte
  * <CollapsibleContentBlock
  *   bind:open
- *   icon={BrainIcon}
  *   title="Thinking..."
  *   isStreaming
  * >
@@ -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
+ * <CollapsibleTerminalBlock bind:open title="Run command">
+ *   <pre>{output}</pre>
+ * </CollapsibleTerminalBlock>
+ * ```
+ */
+export { default as CollapsibleTerminalBlock } from './CollapsibleTerminalBlock.svelte';
+
 /**
  * **MermaidPreview** - Interactive Mermaid diagram viewer
  *
index eb162a5572921b9202c0582b2a4babfa83f9de96..f741b544bb7a55271fc35ef6e8be13bab8f644c1 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { FolderOpen, Plus, Loader2, Braces } from '@lucide/svelte';
        import { toast } from 'svelte-sonner';
        import * as Dialog from '$lib/components/ui/dialog';
                                {#if selectedTemplate && !templatePreviewContent}
                                        <div class="flex h-full flex-col">
                                                <div class="mb-3 flex items-center gap-2">
-                                                       <Braces class="h-4 w-4 text-muted-foreground" />
+                                                       <Braces class="{ICON_CLASS_DEFAULT} text-muted-foreground" />
 
                                                        <span class="text-sm font-medium">
                                                                {selectedTemplate.title || selectedTemplate.name}
                        {#if hasTemplateResult}
                                <Button onclick={handleAttachTemplateResource} disabled={isAttaching}>
                                        {#if isAttaching}
-                                               <Loader2 class="mr-2 h-4 w-4 animate-spin" />
+                                               <Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
                                        {:else}
-                                               <Plus class="mr-2 h-4 w-4" />
+                                               <Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
                                        {/if}
 
                                        Attach Resource
                        {:else}
                                <Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
                                        {#if isAttaching}
-                                               <Loader2 class="mr-2 h-4 w-4 animate-spin" />
+                                               <Loader2 class="mr-2 {ICON_CLASS_DEFAULT} animate-spin" />
                                        {:else}
-                                               <Plus class="mr-2 h-4 w-4" />
+                                               <Plus class="mr-2 {ICON_CLASS_DEFAULT}" />
                                        {/if}
 
                                        Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
index 7438b8fd6545fb400cc5354fc42f061845ca15cf..fa10d5d79c305e71492017d30e2c96a05f8e004b 100644 (file)
@@ -1,11 +1,20 @@
 <script lang="ts">
        import { Button } from '$lib/components/ui/button';
        import * as Dialog from '$lib/components/ui/dialog';
-       import { McpServerForm } from '$lib/components/app/mcp';
+       import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
        import { mcpStore } from '$lib/stores/mcp.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
-       import { parseHeadersToArray, uuid } from '$lib/utils';
-       import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
+       import { parseHeadersToArray, uuid, canonicalizeServerUrl } from '$lib/utils';
+       import {
+               BEARER_PREFIX,
+               BOOL_FALSE_STRING,
+               BOOL_TRUE_STRING,
+               DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
+               MCP_SERVER_ID_PREFIX,
+               RECOMMENDED_MCP_SERVERS,
+               REDACTED_HEADERS
+       } from '$lib/constants';
+       import { browser } from '$app/environment';
 
        interface Props {
                open: boolean;
        let newServerUrl = $state('');
        let newServerHeaders = $state('');
        let newServerUseProxy = $state(false);
+
+       let newServerWantsAuthorization = $state(false);
+
+       let selectedRecommendationId = $derived.by(() => {
+               const url = newServerUrl.trim();
+               if (!url) return null;
+               const targetCanonical = canonicalizeServerUrl(url);
+               return (
+                       RECOMMENDED_MCP_SERVERS.find((rec) => canonicalizeServerUrl(rec.url) === targetCanonical)
+                               ?.id ?? null
+               );
+       });
+       let selectedRecommendation = $derived(
+               selectedRecommendationId
+                       ? (RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === selectedRecommendationId) ?? null)
+                       : null
+       );
+       let authRequired = $derived(selectedRecommendation?.needsAuthorization ?? false);
+
+       let bearerTokenFilled = $derived.by(() => {
+               const pairs = parseHeadersToArray(newServerHeaders);
+               const bearerPrefix = BEARER_PREFIX.toLowerCase();
+               const bearer = pairs.find(
+                       (p) =>
+                               REDACTED_HEADERS.has(p.key.trim().toLowerCase()) &&
+                               p.value.trim().toLowerCase().startsWith(bearerPrefix)
+               );
+
+               if (!bearer) return false;
+
+               return bearer.value.trim().slice(bearerPrefix.length).trim().length > 0;
+       });
+
        let newServerUrlError = $derived.by(() => {
                if (!newServerUrl.trim()) return 'URL is required';
                try {
        let newServerHeaderPairsValid = $derived(
                parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
        );
-       let canSave = $derived(!newServerUrlError && newServerHeaderPairsValid);
+       let canSave = $derived(
+               !newServerUrlError && newServerHeaderPairsValid && (!authRequired || bearerTokenFilled)
+       );
+
+       // Backward-compatible read: older versions stored a JSON array of dismissed ids.
+       function readRecommendationsDismissed(): boolean {
+               if (!browser) return false;
+               const raw = localStorage.getItem(DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY);
+
+               if (!raw) return false;
+
+               if (raw === BOOL_TRUE_STRING) return true;
+               if (raw === BOOL_FALSE_STRING) return false;
+
+               try {
+                       const parsed = JSON.parse(raw);
+                       return Array.isArray(parsed) && parsed.length > 0;
+               } catch {
+                       return false;
+               }
+       }
+
+       function writeRecommendationsDismissed(dismissed: boolean) {
+               recommendationsDismissed = dismissed;
+
+               if (browser) {
+                       localStorage.setItem(
+                               DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
+                               dismissed ? BOOL_TRUE_STRING : BOOL_FALSE_STRING
+                       );
+               }
+       }
+
+       let recommendationsDismissed = $state<boolean>(readRecommendationsDismissed());
+
+       // Read-only once a recommendation is picked: switch is disabled, so we keep
+       // the Authorization field in sync with the requirement.
+       $effect(() => {
+               if (authRequired) {
+                       newServerWantsAuthorization = true;
+               }
+       });
+
+       let hasSelection = $derived(selectedRecommendationId !== null);
+
+       let unconfiguredRecommendations = $derived.by(() => {
+               const configuredCanonicals = new Set(
+                       mcpStore.getServers().map((s) => canonicalizeServerUrl(s.url))
+               );
+
+               return RECOMMENDED_MCP_SERVERS.filter(
+                       (rec) => !configuredCanonicals.has(canonicalizeServerUrl(rec.url))
+               );
+       });
+
+       let recommendationsToShow = $derived(recommendationsDismissed ? [] : unconfiguredRecommendations);
+
+       function handleRecommendationClick(recommendedId: string) {
+               const recommendation = RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === recommendedId);
+
+               if (!recommendation) return;
+
+               newServerUrl = recommendation.url;
+               newServerHeaders = '';
+               newServerWantsAuthorization = recommendation.needsAuthorization ?? false;
+       }
+
+       function handleDismissAll() {
+               writeRecommendationsDismissed(true);
+       }
 
        function handleOpenChange(value: boolean) {
                if (!value) {
                        newServerUrl = '';
                        newServerHeaders = '';
                        newServerUseProxy = false;
+                       newServerWantsAuthorization = false;
                }
                open = value;
                onOpenChange?.(value);
 </script>
 
 <Dialog.Root {open} onOpenChange={handleOpenChange}>
-       <Dialog.Content class="sm:max-w-md">
+       <Dialog.Content class="sm:max-w-2xl">
                <Dialog.Header>
-                       <Dialog.Title>Add New Server</Dialog.Title>
+                       <Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
                </Dialog.Header>
 
+               {#if recommendationsToShow.length > 0}
+                       <div class="space-y-3 pt-2">
+                               <div class="flex items-center justify-between gap-3">
+                                       <h3 class="text-sm font-medium">Recommended Servers</h3>
+                                       <Button class="text-muted-foreground" variant="ghost" size="sm" onclick={handleDismissAll}
+                                               >Dismiss</Button
+                                       >
+                               </div>
+
+                               <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
+                                       {#each recommendationsToShow as recommendation (recommendation.id)}
+                                               <McpServerCardCompact
+                                                       server={recommendation}
+                                                       onClick={() => handleRecommendationClick(recommendation.id)}
+                                                       selected={selectedRecommendationId === recommendation.id}
+                                                       dimmed={hasSelection && selectedRecommendationId !== recommendation.id}
+                                               />
+                                       {/each}
+                               </div>
+                       </div>
+               {/if}
+
                <form onsubmit={handleSubmit} class="contents">
                        <div class="space-y-4 py-4">
                                <McpServerForm
                                        onUseProxyChange={(v) => (newServerUseProxy = v)}
                                        urlError={newServerUrl ? newServerUrlError : null}
                                        id="new-server"
+                                       bind:wantsAuthorization={newServerWantsAuthorization}
+                                       required={authRequired}
                                />
                        </div>
 
index a6c20291fa0a6151ad442270b0057520790fa1f1..89d23cd4b29279d2c698555b08f4109f7b0876f0 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
        import { AlertTriangle, ArrowRight } from '@lucide/svelte';
        import { goto } from '$app/navigation';
@@ -60,7 +61,7 @@
                                                        >
                                                                <span class="min-w-0 truncate font-mono text-xs">{model}</span>
                                                                <ArrowRight
-                                                                       class="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
+                                                                       class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
                                                                />
                                                        </button>
                                                {/each}
index fd6e59a5b83066bf6a644d2a9b4c04505147eb24..f06480774f0db29fc2359ad6967d84ec47f8e16a 100644 (file)
 <div class={className}>
        <div class="mb-2 flex items-center justify-between">
                {#if sectionLabel}
-                       <span class="text-xs font-medium">
+                       <span class="text-xs font-medium select-none">
                                {sectionLabel}
                                {#if sectionLabelOptional}
                                        <span class="text-muted-foreground">(optional)</span>
                        {addButtonLabel}
                </button>
        </div>
+
        {#if pairs.length > 0}
                <div class="space-y-3">
                        {#each pairs as pair, index (index)}
                        {/each}
                </div>
        {:else}
-               <p class="text-xs text-muted-foreground">{emptyMessage}</p>
+               <p class="select-none text-xs text-muted-foreground">{emptyMessage}</p>
        {/if}
 </div>
index c4389c52373afcea8896c0a91af1a597d02616c3..2d29672c4d9eeae481244a4eb272ec02ed94624d 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Input } from '$lib/components/ui/input';
        import { Search, X } from '@lucide/svelte';
 
@@ -50,7 +51,7 @@
 
 <div class="relative {className}">
        <Search
-               class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
+               class="absolute top-1/2 left-3 z-10 {ICON_CLASS_DEFAULT} -translate-y-1/2 transform text-muted-foreground"
        />
 
        <Input
@@ -72,7 +73,7 @@
                        onclick={handleClear}
                        aria-label={value ? 'Clear search' : 'Close'}
                >
-                       <X class="h-4 w-4" />
+                       <X class={ICON_CLASS_DEFAULT} />
                </button>
        {/if}
 </div>
index dd239423ee407e1c9584c78a250e2875a30e2def..d2113ade1583677bcf5a3d1848bd36bb5ca925b6 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import * as Tooltip from '$lib/components/ui/tooltip';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { mcpStore } from '$lib/stores/mcp.svelte';
@@ -50,7 +51,7 @@
        >
                <Tooltip.Root>
                        <Tooltip.Trigger>
-                               <McpLogo class="h-4 w-4" />
+                               <McpLogo class={ICON_CLASS_DEFAULT} />
                        </Tooltip.Trigger>
 
                        <Tooltip.Content>
@@ -68,7 +69,7 @@
                                                        <img
                                                                src={favicon.url}
                                                                alt=""
-                                                               class="h-4 w-4"
+                                                               class={ICON_CLASS_DEFAULT}
                                                                onerror={(e) => {
                                                                        (e.currentTarget as HTMLImageElement).style.display = 'none';
                                                                }}
index 55e1e20a2c7bb222f18a2240285c6b958b0e219f..d2d400bff5677c87ca3de3ed892a7b9775c61a21 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { mcpStore } from '$lib/stores/mcp.svelte';
                                                />
                                        {:else}
                                                <div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
-                                                       <FileText class="h-4 w-4" />
+                                                       <FileText class={ICON_CLASS_DEFAULT} />
 
                                                        <span>Binary content ({blob.mimeType || 'unknown type'})</span>
                                                </div>
index 419654c13ca4708c1f455268a38cdfb32034aba1..e683bcd424bcbf6a72f1977db1bcfcfc09966dbd 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { RefreshCw, Loader2 } from '@lucide/svelte';
        import { Button } from '$lib/components/ui/button';
        import { SearchInput } from '$lib/components/app/forms';
@@ -30,9 +31,9 @@
                        title="Refresh resources"
                >
                        {#if isLoading}
-                               <Loader2 class="h-4 w-4 animate-spin" />
+                               <Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
                        {:else}
-                               <RefreshCw class="h-4 w-4" />
+                               <RefreshCw class={ICON_CLASS_DEFAULT} />
                        {/if}
                </Button>
        </div>
index 9acd101cdfd68b9651eca47e7df99e14a19ea25a..00391e9f8d6b7826aa3035c809b3e6c5446a6e97 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import * as Collapsible from '$lib/components/ui/collapsible';
                                        checked={isSelected}
                                        onCheckedChange={(checked: boolean | 'indeterminate') =>
                                                handleCheckboxChange(resource, checked === true)}
-                                       class="h-4 w-4"
+                                       class={ICON_CLASS_DEFAULT}
                                />
                        {/if}
 
                                <McpServerIdentity
                                        displayName={serverDisplayName}
                                        faviconUrl={serverFaviconUrl}
-                                       iconClass="h-4 w-4"
+                                       iconClass={ICON_CLASS_DEFAULT}
                                        showVersion={false}
                                />
                        </div>
index f7b6e24710b26537a8641cfacf17e6fdd346a733..cf171dcc924347f0af0b6c7c7f87f53c09356a60 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { tick } from 'svelte';
        import * as Card from '$lib/components/ui/card';
        import { Skeleton } from '$lib/components/ui/skeleton';
                        {#if showSkeleton}
                                <div class="space-y-2">
                                        <div class="flex items-center gap-2">
-                                               <Skeleton class="h-4 w-4 rounded" />
+                                               <Skeleton class="{ICON_CLASS_DEFAULT} rounded" />
                                                <Skeleton class="h-3 w-24" />
                                        </div>
                                        <div class="flex flex-wrap gap-1.5">
 
                                <div class="space-y-1.5">
                                        <div class="flex items-center gap-2">
-                                               <Skeleton class="h-4 w-4 rounded" />
+                                               <Skeleton class="{ICON_CLASS_DEFAULT} rounded" />
                                                <Skeleton class="h-3 w-32" />
                                        </div>
                                </div>
diff --git a/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte b/tools/ui/src/lib/components/app/mcp/McpServerCard/McpServerCardCompact.svelte
new file mode 100644 (file)
index 0000000..e70d635
--- /dev/null
@@ -0,0 +1,44 @@
+<script lang="ts">
+       import * as Card from '$lib/components/ui/card';
+       import { mode } from 'mode-watcher';
+       import type { RecommendedMCPServer } from '$lib/types';
+
+       interface Props {
+               server: RecommendedMCPServer;
+               onClick?: () => void;
+               selected?: boolean;
+               dimmed?: boolean;
+       }
+
+       let { server, onClick, selected = false, dimmed = false }: Props = $props();
+
+       let activeIconUrl = $derived.by(() => {
+               const isDark = mode.current === 'dark';
+
+               if (isDark && server.iconUrlDark) return server.iconUrlDark;
+               if (!isDark && server.iconUrlLight) return server.iconUrlLight;
+
+               return server.iconUrl;
+       });
+</script>
+
+<Card.Root
+       class={`relative gap-3! select-none bg-muted/30 p-4 transition-all ${onClick ? 'cursor-pointer hover:bg-muted/50 hover:opacity-100' : ''} ${selected ? 'bg-muted/30 ring-1 ring-primary/40' : ''} ${dimmed ? 'opacity-50' : ''}`}
+       onclick={onClick}
+>
+       <div class="flex min-w-0 items-center gap-2">
+               {#if activeIconUrl}
+                       <img
+                               src={activeIconUrl}
+                               alt=""
+                               class="h-5 w-5 shrink-0 rounded"
+                               loading="lazy"
+                               decoding="async"
+                       />
+               {/if}
+
+               <h4 class="min-w-0 flex-1 truncate font-medium">{server.name}</h4>
+       </div>
+
+       <p class="text-xs text-muted-foreground">{server.description}</p>
+</Card.Root>
index 7f05d5fef31f0f6ccaa2f93d82998f40ab323c3f..a7472add31960b9f589d2932225f4ae0973b304c 100644 (file)
@@ -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;
                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(
 
        let headerPairs = $derived<KeyValuePair[]>(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);
 
 <div class="grid gap-2">
        <div class="mb-4">
-               <label for="server-url-{id}" class="mb-2 block text-xs font-medium">
+               <label for="server-url-{id}" class="mb-2 block text-xs font-medium select-none">
                        Server URL <span class="text-destructive">*</span>
                </label>
 
                {/if}
        </div>
 
-       <label class="flex items-center gap-2 cursor-pointer">
+       <label class="flex items-center gap-2 cursor-pointer select-none">
                <Switch
                        id="use-authorization-{id}"
                        checked={showAuthorization}
                        onCheckedChange={setUseAuthorization}
+                       disabled={required}
                />
 
-               <span class="text-xs text-muted-foreground">Authorization</span>
+               <span class="text-xs text-muted-foreground">
+                       Authorization{#if required}
+                               <span class="text-destructive">*</span>{/if}
+               </span>
        </label>
 
        {#if showAuthorization}
index 3d30bb3b4518f4b7f971b1a84f32cdf835647bd9..73abd31409a1dbe6d3962e8aee0be9358a4d19c8 100644 (file)
@@ -180,6 +180,17 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
 /** Skeleton loading state for server card during health checks. */
 export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
 
+/**
+ * **McpServerCardCompact** - Condensed MCP server card
+ *
+ * Static card for picker-style UIs (e.g. recommended MCP servers in the
+ * Add New Server dialog). Shows an optional favicon, the server name, and
+ * a short description. Performs no network requests - safe to render
+ * without contacting any upstream server until the user explicitly adds
+ * the server.
+ */
+export { default as McpServerCardCompact } from './McpServerCard/McpServerCardCompact.svelte';
+
 /**
  * **McpServerIdentity** - Server identity display (icon, name, version)
  *
index a04f3956f8a5f5cea1f626a54121c91198360550..d5665901a1a4d9a3ac5493ae35e5092744519a24 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { ChevronLeft, ChevronRight } from '@lucide/svelte';
        import type { Snippet } from 'svelte';
 
@@ -71,7 +72,7 @@
                disabled={!canScrollLeft}
                aria-label="Scroll left"
        >
-               <ChevronLeft class="h-4 w-4" />
+               <ChevronLeft class={ICON_CLASS_DEFAULT} />
        </button>
 
        <div
@@ -88,6 +89,6 @@
                disabled={!canScrollRight}
                aria-label="Scroll right"
        >
-               <ChevronRight class="h-4 w-4" />
+               <ChevronRight class={ICON_CLASS_DEFAULT} />
        </button>
 </div>
index 981111e20157d600249826653219a16e54d10f0a..9671615a4f846f6d46493950bdaf378dffe9c622 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import {
                CircleAlert,
                Heart,
 
                {#if isLoading}
                        <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
-                               <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
+                               <Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
                        </div>
                {:else if isFailed}
                        <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
index f118a68dfc670232d29fe5acf6c1e485296ae3ac..ddcaf86b1afbcc7d56eafaf0912ce3e600f86010 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { goto } from '$app/navigation';
        import { page } from '$app/state';
        import { Search } from '@lucide/svelte';
@@ -85,7 +86,7 @@
 </script>
 
 {#snippet itemIcon(IconComponent: Component)}
-       <IconComponent class="h-4 w-4" />
+       <IconComponent class={ICON_CLASS_DEFAULT} />
 {/snippet}
 
 {#if isSearchModeActive}
                                                tooltip={item.tooltip}
                                                tooltipSide={TooltipSide.RIGHT}
                                                size="lg"
-                                               iconSize="h-4 w-4"
+                                               iconSize={ICON_CLASS_DEFAULT}
                                                class="h-9 w-9 rounded-full hover:bg-accent! {isActive
                                                        ? 'bg-accent text-accent-foreground'
                                                        : ''}"
index b1c2b78f65eaa4ccec0b99630ad5a3bfe6f1fbe7..08c66a8a9768cc3745e74525b11943f8ac49d180 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import {
                Trash2,
                Pencil,
                        <Tooltip.Root>
                                <Tooltip.Trigger>
                                        <div
-                                               class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
+                                               class="stop-button flex {ICON_CLASS_DEFAULT} shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
                                                onclick={handleStop}
                                                onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
                                                role="button"
index 4da0d1ddfa800c1803dd144faf231647ab6b01c8..d9c4386e4c109b687a817fef2725f64a359fc292 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { base } from '$app/paths';
        import { AlertTriangle, RefreshCw, Key, CheckCircle, XCircle } from '@lucide/svelte';
        import { goto } from '$app/navigation';
@@ -7,7 +8,7 @@
        import Label from '$lib/components/ui/label/label.svelte';
        import { serverStore, serverLoading } from '$lib/stores/server.svelte';
        import { config, settingsStore } from '$lib/stores/settings.svelte';
-       import { SETTINGS_KEYS } from '$lib/constants';
+       import { AUTHORIZATION_HEADER, BEARER_PREFIX, SETTINGS_KEYS } from '$lib/constants';
        import { ROUTES } from '$lib/constants/routes';
        import { fade, fly, scale } from 'svelte/transition';
        import { KeyboardKey } from '$lib/enums';
@@ -71,7 +72,7 @@
                        const response = await fetch(`${base}/props`, {
                                headers: {
                                        'Content-Type': 'application/json',
-                                       Authorization: `Bearer ${apiKeyInput.trim()}`
+                                       [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKeyInput.trim()}`
                                }
                        });
 
                {#if isAccessDeniedError && !showApiKeyInput}
                        <div in:fly={{ y: 10, duration: 300, delay: 200 }} class="mb-4">
                                <Button onclick={handleShowApiKeyInput} variant="outline" class="w-full">
-                                       <Key class="h-4 w-4" />
+                                       <Key class={ICON_CLASS_DEFAULT} />
                                        Enter API Key
                                </Button>
                        </div>
                                                />
                                                {#if apiKeyState === 'validating'}
                                                        <div class="absolute top-1/2 right-3 -translate-y-1/2">
-                                                               <RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
+                                                               <RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
                                                        </div>
                                                {:else if apiKeyState === 'success'}
                                                        <div
                                                                class="absolute top-1/2 right-3 -translate-y-1/2"
                                                                in:scale={{ duration: 200, start: 0.8 }}
                                                        >
-                                                               <CheckCircle class="h-4 w-4 text-green-500" />
+                                                               <CheckCircle class="{ICON_CLASS_DEFAULT} text-green-500" />
                                                        </div>
                                                {:else if apiKeyState === 'error'}
                                                        <div
                                                                class="absolute top-1/2 right-3 -translate-y-1/2"
                                                                in:scale={{ duration: 200, start: 0.8 }}
                                                        >
-                                                               <XCircle class="h-4 w-4 text-destructive" />
+                                                               <XCircle class="{ICON_CLASS_DEFAULT} text-destructive" />
                                                        </div>
                                                {/if}
                                        </div>
                                                class="flex-1"
                                        >
                                                {#if apiKeyState === 'validating'}
-                                                       <RefreshCw class="h-4 w-4 animate-spin" />
+                                                       <RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin" />
                                                        Validating...
                                                {:else if apiKeyState === 'success'}
                                                        Success!
                        <div in:fly={{ y: 10, duration: 300, delay: 200 }}>
                                <Button onclick={handleRetryConnection} disabled={isServerLoading} class="w-full">
                                        {#if isServerLoading}
-                                               <RefreshCw class="h-4 w-4 animate-spin" />
+                                               <RefreshCw class="{ICON_CLASS_DEFAULT} animate-spin" />
 
                                                Connecting...
                                        {:else}
-                                               <RefreshCw class="h-4 w-4" />
+                                               <RefreshCw class={ICON_CLASS_DEFAULT} />
 
                                                Retry Connection
                                        {/if}
index 86a962de123fa89aaba29a9e3e57a7eff57c911a..ffdf4887c95129ad968d1bfba6fb5a7a6c3414c3 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { AlertTriangle, Server } from '@lucide/svelte';
        import { Badge } from '$lib/components/ui/badge';
        import { Button } from '$lib/components/ui/button';
@@ -57,7 +58,7 @@
 
        {#if showActions && error}
                <Button variant="outline" size="sm" class="text-destructive">
-                       <AlertTriangle class="h-4 w-4" />
+                       <AlertTriangle class={ICON_CLASS_DEFAULT} />
 
                        {error}
                </Button>
index 41105baa5e629b87ee4b5f5babcc153ed9b382e8..5d772359a6d18fb572a748f121c9a7ddec2ea749 100644 (file)
@@ -12,9 +12,9 @@
                NUMERIC_FIELDS,
                POSITIVE_INTEGER_FIELDS,
                SETTINGS_CHAT_SECTIONS,
-               SETTINGS_SECTION_TITLES,
-               type SettingsSection
+               SETTINGS_SECTION_TITLES
        } from '$lib/constants';
+       import type { SettingsSection } from '$lib/types';
        import { RouterService } from '$lib/services/router.service';
        import { setMode } from 'mode-watcher';
        import { ColorMode } from '$lib/enums/ui.enums';
index 7c1c5c8977686ede70254a3e0ec6c51b51c5215c..f5b968ae93fe187b1097012c34b8699e184d32ce 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { RotateCcw, FlaskConical } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import { Input } from '$lib/components/ui/input';
 </script>
 
 {#each fields as field (field.key)}
-       <div class="space-y-2">
-               {#if field.type === SettingsFieldType.INPUT}
-                       {@const currentValue = String(localConfig[field.key] ?? '')}
-                       {@const serverDefault = currentModelParams[field.key]}
-                       {@const isCustomRealTime = (() => {
-                               if (serverDefault == null) return false;
-                               if (currentValue === '') return false;
+       {#if !field.dependsOn || Boolean(localConfig[field.dependsOn])}
+               <div class={field.dependsOn ? 'space-y-2 pl-6' : 'space-y-2'}>
+                       {#if field.type === SettingsFieldType.INPUT}
+                               {@const currentValue = String(localConfig[field.key] ?? '')}
+                               {@const serverDefault = currentModelParams[field.key]}
+                               {@const isCustomRealTime = (() => {
+                                       if (serverDefault == null) return false;
+                                       if (currentValue === '') return false;
 
-                               const numericInput = parseFloat(currentValue);
-                               const normalizedInput = !isNaN(numericInput)
-                                       ? Math.round(numericInput * 1000000) / 1000000
-                                       : currentValue;
-                               const normalizedDefault =
-                                       typeof serverDefault === 'number'
-                                               ? Math.round(serverDefault * 1000000) / 1000000
-                                               : serverDefault;
+                                       const numericInput = parseFloat(currentValue);
+                                       const normalizedInput = !isNaN(numericInput)
+                                               ? Math.round(numericInput * 1000000) / 1000000
+                                               : currentValue;
+                                       const normalizedDefault =
+                                               typeof serverDefault === 'number'
+                                                       ? Math.round(serverDefault * 1000000) / 1000000
+                                                       : serverDefault;
 
-                               return normalizedInput !== normalizedDefault;
-                       })()}
+                                       return normalizedInput !== normalizedDefault;
+                               })()}
 
-                       <div class="flex items-center gap-2">
-                               <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
-                                       {field.label}
+                               <div class="flex items-center gap-2">
+                                       <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
+                                               {field.label}
 
-                                       {#if field.isExperimental}
-                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                               {#if field.isExperimental}
+                                                       <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                               {/if}
+                                       </Label>
+                                       {#if isCustomRealTime}
+                                               <SettingsChatParameterSourceIndicator />
                                        {/if}
-                               </Label>
-                               {#if isCustomRealTime}
-                                       <SettingsChatParameterSourceIndicator />
-                               {/if}
-                       </div>
+                               </div>
 
-                       <div class="relative w-full">
-                               <Input
-                                       id={field.key}
-                                       type={field.isPositiveInteger ? 'number' : 'text'}
-                                       {...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
-                                       value={currentValue}
-                                       oninput={(e) => {
-                                               // Update local config immediately for real-time badge feedback
-                                               onConfigChange(field.key, e.currentTarget.value);
-                                       }}
-                                       placeholder={currentModelParams[field.key] != null
-                                               ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
-                                               : ''}
-                                       class="w-full {isCustomRealTime ? 'pr-8' : ''}"
-                               />
-                               {#if isCustomRealTime}
-                                       <button
-                                               type="button"
-                                               onclick={() => {
-                                                       settingsStore.resetParameterToServerDefault(field.key);
-                                                       onConfigChange(field.key, '');
+                               <div class="relative w-full">
+                                       <Input
+                                               id={field.key}
+                                               type={field.isPositiveInteger ? 'number' : 'text'}
+                                               {...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
+                                               value={currentValue}
+                                               oninput={(e) => {
+                                                       // Update local config immediately for real-time badge feedback
+                                                       onConfigChange(field.key, e.currentTarget.value);
                                                }}
-                                               class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
-                                               aria-label="Reset to default"
-                                               title="Reset to default"
-                                       >
-                                               <RotateCcw class="h-3 w-3" />
-                                       </button>
-                               {/if}
-                       </div>
-                       {#if field.help || SETTING_CONFIG_INFO[field.key]}
-                               <p class="mt-1 text-xs text-muted-foreground">
-                                       {@html field.help || SETTING_CONFIG_INFO[field.key]}
-                               </p>
-                       {/if}
-               {:else if field.type === SettingsFieldType.TEXTAREA}
-                       {#if field.label}
-                               <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
-                                       {field.label}
-
-                                       {#if field.isExperimental}
-                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
-                                       {/if}
-                               </Label>
-                       {/if}
-
-                       <Textarea
-                               id={field.key}
-                               value={String(localConfig[field.key] ?? '')}
-                               onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
-                               placeholder=""
-                               class="min-h-[10rem] w-full md:max-w-3xl"
-                       />
-
-                       {#if field.help || SETTING_CONFIG_INFO[field.key]}
-                               <p class="mt-1 text-xs text-muted-foreground">
-                                       {field.help || SETTING_CONFIG_INFO[field.key]}
-                               </p>
-                       {/if}
-
-                       {#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
-                               <div class="mt-3 flex items-center gap-2">
-                                       <Checkbox
-                                               id="showSystemMessage"
-                                               checked={Boolean(localConfig.showSystemMessage ?? true)}
-                                               onCheckedChange={(checked) =>
-                                                       onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
+                                               placeholder={currentModelParams[field.key] != null
+                                                       ? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
+                                                       : ''}
+                                               class="w-full {isCustomRealTime ? 'pr-8' : ''}"
                                        />
-
-                                       <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
-                                               Show system message in conversations
-                                       </Label>
-                               </div>
-                       {/if}
-               {:else if field.type === SettingsFieldType.SELECT}
-                       {@const selectedOption = field.options?.find(
-                               (opt: { value: string; label: string; icon?: Component }) =>
-                                       opt.value === localConfig[field.key]
-                       )}
-                       {@const currentValue = localConfig[field.key]}
-                       {@const serverDefault = currentModelParams[field.key]}
-                       {@const isCustomRealTime = (() => {
-                               if (serverDefault == null) return false;
-                               if (currentValue === '' || currentValue === undefined) return false;
-                               return currentValue !== serverDefault;
-                       })()}
-
-                       <div class="flex items-center gap-2">
-                               <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
-                                       {field.label}
-
-                                       {#if field.isExperimental}
-                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
-                                       {/if}
-                               </Label>
-                               {#if isCustomRealTime}
-                                       <SettingsChatParameterSourceIndicator />
-                               {/if}
-                       </div>
-
-                       <Select.Root
-                               type="single"
-                               value={currentValue}
-                               onValueChange={(value) => {
-                                       if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
-                                               onThemeChange(value);
-                                       } else {
-                                               onConfigChange(field.key, value);
-                                       }
-                               }}
-                       >
-                               <div class="relative w-full md:w-auto">
-                                       <Select.Trigger class="w-full">
-                                               <div class="flex items-center gap-2">
-                                                       {#if selectedOption?.icon}
-                                                               {@const IconComponent = selectedOption.icon}
-                                                               <IconComponent class="h-4 w-4" />
-                                                       {/if}
-
-                                                       {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
-                                               </div>
-                                       </Select.Trigger>
                                        {#if isCustomRealTime}
                                                <button
                                                        type="button"
                                                                settingsStore.resetParameterToServerDefault(field.key);
                                                                onConfigChange(field.key, '');
                                                        }}
-                                                       class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
+                                                       class="absolute top-1/2 right-2 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
                                                        aria-label="Reset to default"
                                                        title="Reset to default"
                                                >
                                                </button>
                                        {/if}
                                </div>
-                               <Select.Content>
-                                       {#if field.options}
-                                               {#each field.options as option (option.value)}
-                                                       <Select.Item value={option.value} label={option.label}>
-                                                               <div class="flex items-center gap-2">
-                                                                       {#if option.icon}
-                                                                               {@const IconComponent = option.icon}
-                                                                               <IconComponent class="h-4 w-4" />
-                                                                       {/if}
-                                                                       {option.label}
-                                                               </div>
-                                                       </Select.Item>
-                                               {/each}
-                                       {/if}
-                               </Select.Content>
-                       </Select.Root>
-                       {#if field.help || SETTING_CONFIG_INFO[field.key]}
-                               <p class="mt-1 text-xs text-muted-foreground">
-                                       {field.help || SETTING_CONFIG_INFO[field.key]}
-                               </p>
-                       {/if}
-               {:else if field.type === SettingsFieldType.CHECKBOX}
-                       <div class="flex items-start space-x-3">
-                               <Checkbox
+                               {#if field.help || SETTING_CONFIG_INFO[field.key]}
+                                       <p class="mt-1 text-xs text-muted-foreground">
+                                               {@html field.help || SETTING_CONFIG_INFO[field.key]}
+                                       </p>
+                               {/if}
+                       {:else if field.type === SettingsFieldType.TEXTAREA}
+                               {#if field.label}
+                                       <Label for={field.key} class="block flex items-center gap-1.5 text-sm font-medium">
+                                               {field.label}
+
+                                               {#if field.isExperimental}
+                                                       <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                               {/if}
+                                       </Label>
+                               {/if}
+
+                               <Textarea
                                        id={field.key}
-                                       checked={Boolean(localConfig[field.key])}
-                                       onCheckedChange={(checked) => onConfigChange(field.key, checked)}
-                                       class="mt-1"
+                                       value={String(localConfig[field.key] ?? '')}
+                                       onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
+                                       placeholder=""
+                                       class="min-h-[10rem] w-full md:max-w-3xl"
                                />
 
-                               <div class="space-y-1">
-                                       <label
-                                               for={field.key}
-                                               class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
-                                       >
+                               {#if field.help || SETTING_CONFIG_INFO[field.key]}
+                                       <p class="mt-1 text-xs text-muted-foreground">
+                                               {field.help || SETTING_CONFIG_INFO[field.key]}
+                                       </p>
+                               {/if}
+
+                               {#if field.key === SETTINGS_KEYS.SYSTEM_MESSAGE}
+                                       <div class="mt-3 flex items-center gap-2">
+                                               <Checkbox
+                                                       id="showSystemMessage"
+                                                       checked={Boolean(localConfig.showSystemMessage ?? true)}
+                                                       onCheckedChange={(checked) =>
+                                                               onConfigChange(SETTINGS_KEYS.SHOW_SYSTEM_MESSAGE, Boolean(checked))}
+                                               />
+
+                                               <Label for="showSystemMessage" class="cursor-pointer text-sm font-normal">
+                                                       Show system message in conversations
+                                               </Label>
+                                       </div>
+                               {/if}
+                       {:else if field.type === SettingsFieldType.SELECT}
+                               {@const selectedOption = field.options?.find(
+                                       (opt: { value: string; label: string; icon?: Component }) =>
+                                               opt.value === localConfig[field.key]
+                               )}
+                               {@const currentValue = localConfig[field.key]}
+                               {@const serverDefault = currentModelParams[field.key]}
+                               {@const isCustomRealTime = (() => {
+                                       if (serverDefault == null) return false;
+                                       if (currentValue === '' || currentValue === undefined) return false;
+                                       return currentValue !== serverDefault;
+                               })()}
+
+                               <div class="flex items-center gap-2">
+                                       <Label for={field.key} class="flex items-center gap-1.5 text-sm font-medium">
                                                {field.label}
 
                                                {#if field.isExperimental}
                                                        <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
                                                {/if}
-                                       </label>
-
-                                       {#if field.help || SETTING_CONFIG_INFO[field.key]}
-                                               <p class="text-xs text-muted-foreground">
-                                                       {field.help || SETTING_CONFIG_INFO[field.key]}
-                                               </p>
+                                       </Label>
+                                       {#if isCustomRealTime}
+                                               <SettingsChatParameterSourceIndicator />
                                        {/if}
                                </div>
-                       </div>
-               {/if}
-       </div>
+
+                               <Select.Root
+                                       type="single"
+                                       value={currentValue}
+                                       onValueChange={(value) => {
+                                               if (field.key === SETTINGS_KEYS.THEME && value && onThemeChange) {
+                                                       onThemeChange(value);
+                                               } else {
+                                                       onConfigChange(field.key, value);
+                                               }
+                                       }}
+                               >
+                                       <div class="relative w-full md:w-auto">
+                                               <Select.Trigger class="w-full">
+                                                       <div class="flex items-center gap-2">
+                                                               {#if selectedOption?.icon}
+                                                                       {@const IconComponent = selectedOption.icon}
+                                                                       <IconComponent class={ICON_CLASS_DEFAULT} />
+                                                               {/if}
+
+                                                               {selectedOption?.label || `Select ${field.label.toLowerCase()}`}
+                                                       </div>
+                                               </Select.Trigger>
+                                               {#if isCustomRealTime}
+                                                       <button
+                                                               type="button"
+                                                               onclick={() => {
+                                                                       settingsStore.resetParameterToServerDefault(field.key);
+                                                                       onConfigChange(field.key, '');
+                                                               }}
+                                                               class="absolute top-1/2 right-8 inline-flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded transition-colors hover:bg-muted"
+                                                               aria-label="Reset to default"
+                                                               title="Reset to default"
+                                                       >
+                                                               <RotateCcw class="h-3 w-3" />
+                                                       </button>
+                                               {/if}
+                                       </div>
+                                       <Select.Content>
+                                               {#if field.options}
+                                                       {#each field.options as option (option.value)}
+                                                               <Select.Item value={option.value} label={option.label}>
+                                                                       <div class="flex items-center gap-2">
+                                                                               {#if option.icon}
+                                                                                       {@const IconComponent = option.icon}
+                                                                                       <IconComponent class={ICON_CLASS_DEFAULT} />
+                                                                               {/if}
+                                                                               {option.label}
+                                                                       </div>
+                                                               </Select.Item>
+                                                       {/each}
+                                               {/if}
+                                       </Select.Content>
+                               </Select.Root>
+                               {#if field.help || SETTING_CONFIG_INFO[field.key]}
+                                       <p class="mt-1 text-xs text-muted-foreground">
+                                               {field.help || SETTING_CONFIG_INFO[field.key]}
+                                       </p>
+                               {/if}
+                       {:else if field.type === SettingsFieldType.CHECKBOX}
+                               <div class="flex items-start space-x-3">
+                                       <Checkbox
+                                               id={field.key}
+                                               checked={Boolean(localConfig[field.key])}
+                                               onCheckedChange={(checked) => onConfigChange(field.key, checked)}
+                                               class="mt-1"
+                                       />
+
+                                       <div class="space-y-1">
+                                               <label
+                                                       for={field.key}
+                                                       class="flex cursor-pointer items-center gap-1.5 pt-1 pb-0.5 text-sm leading-none font-medium"
+                                               >
+                                                       {field.label}
+
+                                                       {#if field.isExperimental}
+                                                               <FlaskConical class="h-3.5 w-3.5 text-muted-foreground" />
+                                                       {/if}
+                                               </label>
+
+                                               {#if field.help || SETTING_CONFIG_INFO[field.key]}
+                                                       <p class="text-xs text-muted-foreground">
+                                                               {field.help || SETTING_CONFIG_INFO[field.key]}
+                                                       </p>
+                                               {/if}
+                                       </div>
+                               </div>
+                       {/if}
+               </div>
+       {/if}
 {/each}
index 6b36526c333491db5c431223dfe99b72a0e4f59d..39921d09802dc71b67c9183d53b818fbf7e867a0 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import type { Component } from 'svelte';
        import { Button, type ButtonVariant } from '$lib/components/ui/button';
 
@@ -36,7 +37,7 @@
        <p class="mb-4 text-sm text-muted-foreground">{description}</p>
 
        <Button class={sectionButtonClass} {onclick} variant={sectionButtonVariant}>
-               <IconComponent class="mr-2 h-4 w-4" />
+               <IconComponent class="mr-2 {ICON_CLASS_DEFAULT}" />
 
                {buttonText}
        </Button>
index 8bb49a0958cd4ec52b66a7355be55880412d52b5..c6d03b41c1a3400363f18b67ab665760ee36a9e8 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { ChevronDown, ChevronRight } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import * as Collapsible from '$lib/components/ui/collapsible';
@@ -41,7 +42,7 @@
                                        <span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
                                                {#if group.source === 'mcp'}
                                                        <McpServerIdentity
-                                                               iconClass="h-4 w-4"
+                                                               iconClass={ICON_CLASS_DEFAULT}
                                                                iconRounded="rounded-sm"
                                                                showVersion={false}
                                                                displayName={group.label}
@@ -79,7 +80,7 @@
                                                                        <Checkbox
                                                                                checked={isEnabled}
                                                                                onCheckedChange={() => toolsStore.toggleTool(entry.key)}
-                                                                               class="h-4 w-4"
+                                                                               class={ICON_CLASS_DEFAULT}
                                                                        />
                                                                </div>
 
@@ -93,7 +94,7 @@
                                                                                                permissionsStore.allowTool(permissionKey);
                                                                                        }
                                                                                }}
-                                                                               class="h-4 w-4"
+                                                                               class={ICON_CLASS_DEFAULT}
                                                                        />
                                                                </div>
                                                        </div>
index 8723217b29a90662b05b6978ecbf595884794138..d8e265dbd237c4f88816611c1d82f25e7ef154d7 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Settings } from '@lucide/svelte';
-       import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
+       import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
 
        interface Props {
                sections: SettingsSection[];
@@ -30,7 +31,7 @@
                                                : 'text-muted-foreground'}"
                                        href={getHref(section)}
                                >
-                                       <section.icon class="h-4 w-4" />
+                                       <section.icon class={ICON_CLASS_DEFAULT} />
                                        <span class="ml-2">{section.title}</span>
                                </a>
                        {:else}
@@ -42,7 +43,7 @@
                                                : 'text-muted-foreground'}"
                                        onclick={() => onSectionChange?.(section.title)}
                                >
-                                       <section.icon class="h-4 w-4" />
+                                       <section.icon class={ICON_CLASS_DEFAULT} />
                                        <span class="ml-2">{section.title}</span>
                                </button>
                        {/if}
index 15eedd108b1e9935ad1eede458d5475fb7818cd0..1383b65f18a199e9af2b808e3a3061e338464401 100644 (file)
@@ -1,7 +1,8 @@
 <script lang="ts">
+       import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
        import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
        import { onMount, tick } from 'svelte';
-       import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
+       import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
        import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
 
        interface Props {
@@ -46,7 +47,7 @@
                                onclick={carousel.scrollLeft}
                                aria-label="Scroll left"
                        >
-                               <ChevronLeft class="h-4 w-4" />
+                               <ChevronLeft class={ICON_CLASS_DEFAULT} />
                        </button>
 
                        <div
@@ -69,7 +70,7 @@
                                                                        carousel.scrollToCenter(e.currentTarget as HTMLElement);
                                                                }}
                                                        >
-                                                               <section.icon class="h-4 w-4 flex-shrink-0" />
+                                                               <section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
                                                                <span>{section.title}</span>
                                                        </a>
                                                {:else}
@@ -85,7 +86,7 @@
                                                                        carousel.scrollToCenter(e.currentTarget as HTMLElement);
                                                                }}
                                                        >
-                                                               <section.icon class="h-4 w-4 flex-shrink-0" />
+                                                               <section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
                                                                <span>{section.title}</span>
                                                        </button>
                                                {/if}
                                onclick={carousel.scrollRight}
                                aria-label="Scroll right"
                        >
-                               <ChevronRight class="h-4 w-4" />
+                               <ChevronRight class={ICON_CLASS_DEFAULT} />
                        </button>
                </div>
        </div>
index bff2caa5b4305dc702dbf00edb3e1ef7eaeff8f4..49fc36a586f5a4156e9faec4236b49504a59d7e0 100644 (file)
@@ -22,8 +22,6 @@
 
        let { class: className }: Props = $props();
 
-       // Every configured server is listed; `enabled` is an on/off state,
-       // not a visibility filter, so a disabled server stays toggleable.
        let servers = $derived(mcpStore.getServers());
 
        let isAddingServer = $state(false);
                                                        const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
                                                        await conversationsStore.toggleMcpServerForChat(server.id);
                                                        if (!wasEnabled) {
+                                                               // Promote the connection so tools/prompts/resources become
+                                                               // available right away instead of waiting for the next chat-init.
+                                                               await mcpStore.runHealthCheck(server, true);
                                                                toolsStore.enableAllToolsForServer(server.id);
                                                        }
                                                }}
index ef2c5fc150b9561cd7b868771c40513ffc5097e5..e63d5c259a3726be7b3bfdce931472d59fe7c61c 100644 (file)
@@ -2,7 +2,31 @@ import type { AgenticConfig } from '$lib/types/agentic';
 
 export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
 
-export const NEWLINE_SEPARATOR = '\n';
+// JSON detection: trimmed content opens with an object or array literal.
+export const TOOL_RESULT_JSON_OPEN_REGEX = /^[[{]/;
+
+// Markdown structural markers used by `looksLikeMarkdown`. Inline / line-level.
+export const MARKDOWN_CODE_FENCE_REGEX = /^(```|~~~)/m;
+export const MARKDOWN_ATX_HEADING_REGEX = /^#{1,6}\s+\S/;
+export const MARKDOWN_BLOCKQUOTE_REGEX = /^>\s+\S/;
+export const MARKDOWN_LIST_BULLET_REGEX = /^\s*[-*+]\s+\S/;
+export const MARKDOWN_LIST_NUMBERED_REGEX = /^\s*\d+[.)]\s+\S/;
+export const MARKDOWN_LINK_REGEX = /\[[^\]\n]+\]\([^)\s]+\)/;
+export const MARKDOWN_BOLD_REGEX = /\*\*[^*\n]+\*\*|__[^_\n]+__/;
+export const MARKDOWN_TABLE_SEPARATOR_REGEX = /^\s*\|?[\s:|-]+\|?\s*$/;
+
+// Search-summary wire format used by file-glob and grep tools:
+//   <matches>
+//   ---
+//   Total matches: N
+export const SEARCH_SUMMARY_SEPARATOR = '---\n';
+export const SEARCH_SUMMARY_TOTAL_REGEX = /Total matches:\s*(\d+)/;
+
+// Separator rendered between stats in the tool-result footer (e.g. between a
+// result message and the byte/edit count). Plain ASCII spaces bracket a hyphen
+// so the whole " - " sits on one visual line even when the surrounding text
+// wraps mid-paragraph.
+export const RESULT_STAT_SEPARATOR = ' - ';
 
 export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
        enabled: true,
index ca9ba5a9e832697ba1d8172eca8e8dd53dea5e6b..c629228eb485c4540c478156ae8f4d9cda46f3fe 100644 (file)
@@ -1,2 +1,16 @@
 export const AUTO_SCROLL_INTERVAL = 100;
+// Chat main view: tight threshold because scroll-here events come from
+// discrete assistant-message appends.
 export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
+// Reasoning block: stickier because reasoning fires many small
+// incremental DOM writes that easily drift a few pixels off bottom.
+export const REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
+// Syntax-highlighted code: stickier than the chat main view because line
+// wrap reflows while the highlight.js pass settles can drift a few pixels
+// off bottom.
+export const SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX = 32;
+// Streaming tool output (e.g. exec_shell_command): shell commands produce
+// lots of small line writes and the exit-code line appended at the tail
+// past the last user-visible frame is what triggers DOM drift, so use a
+// threshold generous enough to capture that tail flush.
+export const TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX = 64;
diff --git a/tools/ui/src/lib/constants/built-in-tools.ts b/tools/ui/src/lib/constants/built-in-tools.ts
new file mode 100644 (file)
index 0000000..0721e64
--- /dev/null
@@ -0,0 +1,59 @@
+// Registry of built-in and frontend (browser) tools whose renderer
+// shows a recognizable icon and friendly label inline in the chat UI.
+//
+// To add a new built-in tool, add an entry to BUILTIN_TOOL_UI. To give a
+// tool a custom title or body renderer, add a dedicated component under
+// ChatMessageToolCall/ and route it in ChatMessageToolCallBlock.svelte
+// (see ChatMessageToolCallBlockGetDatetime and
+// ChatMessageToolCallBlockSearchResults for prior art).
+
+import type { Component } from 'svelte';
+import {
+       Braces,
+       Clock,
+       FilePen,
+       FilePlus,
+       FileSearch,
+       FileText,
+       SearchCode,
+       Terminal
+} from '@lucide/svelte';
+import { BuiltInTool, ToolSource } from '$lib/enums';
+
+export interface BuiltinToolUiEntry {
+       icon: Component;
+       label: string;
+       source: ToolSource.BUILTIN | ToolSource.FRONTEND;
+}
+
+export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>> = {
+       [BuiltInTool.READ_FILE]: { icon: FileText, label: 'Read file', source: ToolSource.BUILTIN },
+       [BuiltInTool.EDIT_FILE]: { icon: FilePen, label: 'Edit file', source: ToolSource.BUILTIN },
+       [BuiltInTool.WRITE_FILE]: { icon: FilePlus, label: 'Write file', source: ToolSource.BUILTIN },
+       [BuiltInTool.FILE_GLOB_SEARCH]: {
+               icon: FileSearch,
+               label: 'Search files',
+               source: ToolSource.BUILTIN
+       },
+       [BuiltInTool.GREP_SEARCH]: {
+               icon: SearchCode,
+               label: 'Search in files',
+               source: ToolSource.BUILTIN
+       },
+       [BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
+       [BuiltInTool.EXEC_SHELL_COMMAND]: {
+               icon: Terminal,
+               label: 'Run command',
+               source: ToolSource.BUILTIN
+       },
+       [BuiltInTool.RUN_JAVASCRIPT]: {
+               icon: Braces,
+               label: 'Run JavaScript',
+               source: ToolSource.FRONTEND
+       }
+} as const;
+
+export function getBuiltinToolUi(toolName: string | undefined): BuiltinToolUiEntry | null {
+       if (!toolName) return null;
+       return (BUILTIN_TOOL_UI as Record<string, BuiltinToolUiEntry>)[toolName] ?? null;
+}
index b55513d7420d1fc756268a2ac6a3f80c59f6f1d8..e57e1e6ec575ae0432d6d9885e932185a0860c30 100644 (file)
@@ -6,3 +6,19 @@ export const AMPERSAND_REGEX = /&/g;
 export const LT_REGEX = /</g;
 export const GT_REGEX = />/g;
 export const FENCE_PATTERN = /^```|\n```/g;
+
+// Whitespace-only empty lines (between start of string and first non-empty line).
+// Used by trimCodePadding to drop leading/trailing phantom blank rows from LLM
+// payload wrappers without touching internal blank lines.
+export const TRIM_LEADING_PADDING_REGEX = /^(?:[ \t]*\n)+/;
+export const TRIM_TRAILING_PADDING_REGEX = /(?:\n[ \t]*)+$/;
+
+// Matches either Unix or Windows path separators so `String.split(REGEX)` can
+// recover the trailing file-name segment from either `/foo/bar.txt` or
+// `C:\foo\bar.txt`. Used wherever a parameter accepts a user-supplied path.
+export const FILE_PATH_SEPARATOR_REGEX = /[\\/]/;
+
+// Matches the `text:` prefix that file-type identifiers use to denote a
+// plain-text language (e.g. `text:typescript`). Used by tool-call renderers
+// to recover the underlying highlight.js language.
+export const TEXT_LANGUAGE_PREFIX_REGEX = /^text:/;
index 009b5c52eeb9fab6d33d117028d133f36d83d34d..3acf16938d8b58885eb63317317aafd90a99d6bf 100644 (file)
@@ -18,3 +18,9 @@ export const PANEL_CLASSES = `
 
 export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
 export const DIALOG_SUBMENU_CONTENT = 'w-60';
+
+/** Default Tailwind size class for inline icon components (lucide, etc.). */
+export const ICON_CLASS_DEFAULT = 'h-4 w-4';
+
+/** Icon size + spinning animation; used for live-streaming tool indicators. */
+export const ICON_CLASS_SPIN = 'h-4 w-4 animate-spin';
index c417faea43d93b5771895b53fde98fb7678354b3..d6d1b883ffe96e76d8b9fe8720b75e511b9b8d8b 100644 (file)
@@ -6,30 +6,3 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
 
 /** Default display value when no performance time is available */
 export const DEFAULT_PERFORMANCE_TIME = '0s';
-
-/** Max length before reasoning preview is truncated */
-export const MAX_PREVIEW_LENGTH = 120;
-
-export const STRIP_MARKDOWN_CAPTURE_PATTERNS: [RegExp, string][] = [
-       [/^```(.*)/gm, '$1'],
-       [/(.*)```$/gm, '$1'],
-       [/`([^`]*)`/g, '$1'],
-       [/\*\*(.*?)\*\*/g, '$1'],
-       [/__(.*?)__/g, '$1'],
-       [/\*(.*?)\*/g, '$1'],
-       [/_(.*?)_/g, '$1']
-];
-
-/* eslint-disable no-misleading-character-class */
-export const STRIP_MARKDOWN_INLINE_REGEX = new RegExp(
-       [
-               '<[^>]*>',
-               '^>\\s*',
-               '^#{1,6}\\s+',
-               '^[\\s]*[-*+]\\s+',
-               '^[\\s]*\\d+[.)]\\s+',
-               '[\\u{1F600}-\\u{1F64F}\\u{1F300}-\\u{1F5FF}\\u{1F680}-\\u{1F6FF}\\u{1F1E0}-\\u{1F1FF}\\u{2600}-\\u{26FF}\\u{2700}-\\u{27BF}\\u{FE00}-\\u{FE0F}\\u{1F900}-\\u{1F9FF}\\u{1FA00}-\\u{1FA6F}\\u{1FA70}-\\u{1FAFF}\\u{200D}\\u{20E3}\\u{231A}-\\u{231B}\\u{23E9}-\\u{23F3}\\u{23F8}-\\u{23FA}\\u{25AA}-\\u{25AB}\\u{25B6}\\u{25C0}\\u{25FB}-\\u{25FE}\\u{2934}-\\u{2935}\\u{2B05}-\\u{2B07}\\u{2B1B}-\\u{2B1C}\\u{2B50}\\u{2B55}\\u{3030}\\u{303D}\\u{3297}\\u{3299}]'
-       ].join('|'),
-       'gmu'
-);
-/* eslint-enable no-misleading-character-class */
index b982a59072751650607b40d1e4b47e3fd15f6d98..68b5992c2f717799344dd75df16d36e4a42ffaf5 100644 (file)
@@ -8,10 +8,12 @@ export * from './attachment-labels';
 export * from './database';
 export * from './reasoning-effort';
 export * from './reasoning-effort-tokens';
+export * from './recommended-mcp-servers';
 export * from './storage';
 export * from './attachment-menu';
 export * from './auto-scroll';
 export * from './binary-detection';
+export * from './built-in-tools';
 export * from './cache';
 export * from './chat-form';
 export * from './cli-flags';
index 783d31a22c11ef0ab2f12f9399edd626d1695017..1cace78a30ebd3951a6b85201633606676465b68 100644 (file)
@@ -2,3 +2,4 @@ export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])';
 export const DATA_ERROR_BOUND_ATTR = 'errorBound';
 export const DATA_ERROR_HANDLED_ATTR = 'errorHandled';
 export const BOOL_TRUE_STRING = 'true';
+export const BOOL_FALSE_STRING = 'false';
index a7381df0bff13b7ce0a14377b7e4a55d0f8058fb..0794883e8fa90e844bda89c2e4e8073ab40736ef 100644 (file)
@@ -62,6 +62,12 @@ export const MCP_PARTIAL_REDACT_HEADERS = new Map<string, number>([
        ['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]
 ]);
 
+/** Bearer scheme prefix used for Authorization headers (RFC 6750) */
+export const BEARER_PREFIX = 'Bearer ';
+
+/** Canonical casing for the Authorization header (RFC 7235) */
+export const AUTHORIZATION_HEADER = 'Authorization';
+
 /** Header names whose values should be redacted in diagnostic logs */
 export const REDACTED_HEADERS = new Set([
        'authorization',
diff --git a/tools/ui/src/lib/constants/recommended-mcp-servers.ts b/tools/ui/src/lib/constants/recommended-mcp-servers.ts
new file mode 100644 (file)
index 0000000..f8ca18c
--- /dev/null
@@ -0,0 +1,38 @@
+import type { RecommendedMCPServer } from '$lib/types';
+
+// Suggested MCP servers shown as opt-in cards in the "Add New Server" dialog.
+// Rendering these cards never reaches the upstream domain - favicons come
+// from local bundles in static/recommended-mcp/ and the URL is only used
+// after the user clicks Add.
+export const RECOMMENDED_MCP_SERVERS: RecommendedMCPServer[] = [
+       {
+               id: 'exa',
+               name: 'Exa',
+               description: 'Search the web and fetch full page content as clean markdown.',
+               url: 'https://mcp.exa.ai/mcp',
+               iconUrl: '/recommended-mcp/exa.ico'
+       },
+       {
+               id: 'huggingface',
+               name: 'Hugging Face',
+               description: 'Search and browse AI models, datasets, spaces, and docs on the Hugging Face Hub.',
+               url: 'https://huggingface.co/mcp',
+               iconUrl: '/recommended-mcp/huggingface.ico'
+       },
+       {
+               id: 'github',
+               name: 'GitHub',
+               description: 'Search repositories, issues, pull requests and interact with code on GitHub.',
+               url: 'https://api.githubcopilot.com/mcp',
+               iconUrlLight: '/recommended-mcp/github-light.png',
+               iconUrlDark: '/recommended-mcp/github-dark.png',
+               needsAuthorization: true
+       },
+       {
+               id: 'context7',
+               name: 'Context7',
+               description: 'Browse up-to-date documentation and code examples for libraries and frameworks.',
+               url: 'https://mcp.context7.com/mcp',
+               iconUrl: '/recommended-mcp/context7.png'
+       }
+];
index 30e769509e820018c933c64e6a7c1dfaf65a57dd..de49f05847c1a3c0aaa9f1d17ac6fb6da8a66cf8 100644 (file)
@@ -1,7 +1,7 @@
-import { JsonSchemaType, ToolCallType } from '$lib/enums';
+import { BuiltInTool, JsonSchemaType, ToolCallType } from '$lib/enums';
 import type { OpenAIToolDefinition } from '$lib/types';
 
-export const SANDBOX_TOOL_NAME = 'run_javascript';
+export const SANDBOX_TOOL_NAME = BuiltInTool.RUN_JAVASCRIPT;
 
 export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
 
index 6be56649ecf20831313513c83b7e8d7ff78d715c..0761ef8f0913b9b57f5c9dfe486e5f449a7e045a 100644 (file)
@@ -21,6 +21,7 @@ export const SETTINGS_KEYS = {
        MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
        // Display
        SHOW_MESSAGE_STATS: 'showMessageStats',
+       SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
        SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
        AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
        RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
index 920a21ae7cc15c228d43a945e5fa805b1f4b55e3..b1404093c5b0d4deababf4fbf9032c27d1773dbd 100644 (file)
@@ -223,7 +223,7 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
                                label: 'Show message generation statistics',
                                help: 'Display generation statistics (tokens/second, token count, duration) below each assistant message.',
-                               defaultValue: true,
+                               defaultValue: false,
                                type: SettingsFieldType.CHECKBOX,
                                section: SETTINGS_SECTION_SLUGS.DISPLAY,
                                sync: {
@@ -231,6 +231,15 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
                                        paramType: SyncableParameterType.BOOLEAN
                                }
                        },
+                       {
+                               key: SETTINGS_KEYS.SHOW_AGENTIC_TURN_STATS,
+                               label: 'Show statistics for individual agentic turns',
+                               help: 'Display per-turn statistics (tokens, duration) under each turn in agentic responses. Shown only when "Show message generation statistics" is enabled.',
+                               defaultValue: false,
+                               type: SettingsFieldType.CHECKBOX,
+                               section: SETTINGS_SECTION_SLUGS.DISPLAY,
+                               dependsOn: SETTINGS_KEYS.SHOW_MESSAGE_STATS
+                       },
                        {
                                key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
                                label: 'Show thought in progress',
@@ -789,9 +798,6 @@ export const SETTING_CONFIG_INFO: Record<string, string> = Object.fromEntries(
 /** Theme select options. */
 export const SETTINGS_COLOR_MODES_CONFIG = COLOR_MODE_OPTIONS;
 
-export type { SettingsSectionTitle } from '$lib/types';
-export type { SettingsSection } from '$lib/types';
-
 /** Sidebar sections + field configs (as consumed by UI). */
 export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
        ...Object.values(SETTINGS_REGISTRY).map((section) => ({
@@ -804,6 +810,7 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
                        type: s.type,
                        isExperimental: s.isExperimental,
                        isPositiveInteger: s.isPositiveInteger,
+                       dependsOn: s.dependsOn,
                        help: s.help,
                        options: s.options
                }))
@@ -832,5 +839,3 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = getAllSettings()
        }));
 
 export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
-
-export { SETTINGS_KEYS } from './settings-keys';
index 4f130be942ff3704d6c3a08decf66f5255d97510..5d9acaafbf731207e97b1d0deeb11355acd55a9e 100644 (file)
@@ -23,6 +23,7 @@ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabled
 export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
 export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
 export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
+export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
 
 /** Key prefix for per-conversation resumable stream state, conversationId is appended */
 export const STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX = `${STORAGE_APP_NAME}.streamResume.`;
index f2d3e95e01c648ef5dfaf5e42293b19f2a3c05df..98a074da099568ffbb5211c53895a1de751d99e1 100644 (file)
@@ -9,6 +9,9 @@ export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
 export const ICON_STRIP_TRANSITION_DURATION = 150;
 export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
 
+/** Max height for tool-result code blocks (json / source / diff / streaming code). */
+export const MAX_HEIGHT_CODE_BLOCK = '22rem';
+
 export interface DesktopIconStripItem {
        icon: Component;
        tooltip: string;
index 0afb9deccb8a3adb4862ae3fbc70d6670a792dec..dd0962d1851c6f41f51a780a4d8a8aa0db4686a3 100644 (file)
@@ -184,3 +184,6 @@ function buildSuffixSet(suffixes: Record<string, readonly string[]>): Set<string
 
 export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES);
 export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
+
+// Matches one or more trailing "/" characters at the end of a URL/path.
+export const TRAILING_SLASHES_REGEX = /\/+$/;
index 9ad7b4f1a87c19a021f963a4195440917c21a3ac..59e996e93e55af9283648227f53233e6a6fb1e86 100644 (file)
@@ -25,3 +25,21 @@ export enum ContinueIntentKind {
        RERUN_TURN = 'rerun_turn',
        NEXT_TURN = 'next_turn'
 }
+
+/**
+ * Renderer tier for a tool-result blob shown in the default tool-call block.
+ */
+export enum ToolResultKind {
+       JSON = 'json',
+       MARKDOWN = 'markdown',
+       TEXT = 'text'
+}
+
+/**
+ * Line classification for the unified-diff renderer of `edit_file` results.
+ */
+export enum DiffLineKind {
+       CONTEXT = 'context',
+       ADD = 'add',
+       REMOVE = 'remove'
+}
index 847014e78c24338853a243e5e4dc80b2d28dfef8..2f70e063d4e01f5b3d0a2d56b4db0178347d3df6 100644 (file)
@@ -6,7 +6,13 @@ export {
        AttachmentItemVisibleWhen
 } from './attachment.enums';
 
-export { AgenticSectionType, ContinueIntentKind, ToolCallType } from './agentic.enums';
+export {
+       AgenticSectionType,
+       ContinueIntentKind,
+       DiffLineKind,
+       ToolResultKind,
+       ToolCallType
+} from './agentic.enums';
 
 export {
        ChatMessageStatsView,
@@ -64,6 +70,6 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
 
 export { KeyboardKey } from './keyboard.enums';
 
-export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
+export { BuiltInTool, ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
 
 export { SplashOrientation } from './splash.enums';
index 120c1d471d64b01f916331499597d32948cc41f6..9985e1f4aa986b6d1b5d931b368240c4408d7f0a 100644 (file)
@@ -16,3 +16,22 @@ export enum ToolResponseField {
        PLAIN_TEXT = 'plain_text_response',
        ERROR = 'error'
 }
+
+/**
+ * Wire-format identifiers for built-in and frontend tools. The string
+ * value matches what the model emits in tool call names, so comparing
+ * against `BuiltInTool.READ_FILE` is equivalent to comparing against the
+ * raw `'read_file'` literal - the enum just keeps the two in lock-step
+ * and gives TypeScript a single source of truth for autocomplete / rename
+ * support.
+ */
+export enum BuiltInTool {
+       READ_FILE = 'read_file',
+       EDIT_FILE = 'edit_file',
+       WRITE_FILE = 'write_file',
+       GET_DATETIME = 'get_datetime',
+       FILE_GLOB_SEARCH = 'file_glob_search',
+       GREP_SEARCH = 'grep_search',
+       EXEC_SHELL_COMMAND = 'exec_shell_command',
+       RUN_JAVASCRIPT = 'run_javascript'
+}
diff --git a/tools/ui/src/lib/hooks/use-throttle.svelte.ts b/tools/ui/src/lib/hooks/use-throttle.svelte.ts
deleted file mode 100644 (file)
index 0795519..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * Creates a reactive throttle key that increments when `getValue()` changes
- * and the throttle window has elapsed since the last increment.
- *
- * Useful for throttling animations that should not fire on every rapid update.
- *
- * @param getValue - A reactive getter for the value to watch
- * @param ms - Throttle window in milliseconds
- * @returns A reactive number that increments when the throttled value changes
- */
-export function useThrottle(getValue: () => string | undefined, ms: number) {
-       let key = $state(0);
-       let throttleEnd = $state(0);
-       let lastValue: string | undefined = getValue();
-
-       $effect(() => {
-               const value = getValue();
-               if (value === lastValue) return;
-               const now = Date.now();
-               if (now >= throttleEnd) {
-                       lastValue = value;
-                       key++;
-                       throttleEnd = now + ms;
-               }
-       });
-
-       return {
-               get key() {
-                       return key;
-               }
-       };
-}
index c5e4093f3a363fa1d6f85bd530cd0405bb555c77..53004729bfd8f09aadb76dabdcf840d9d785a941 100644 (file)
@@ -28,7 +28,7 @@ import {
        NEW_TO_DEPRECATED_MAP
 } from '$lib/constants';
 import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants/agentic';
-import { SETTINGS_KEYS } from '$lib/constants/settings-registry';
+import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
 import { MessageRole } from '$lib/enums';
 
 // Types
index ef0e6cf4eb445ca0c15cee1011568df993526de7..36bf2d0203bbbdce90e3e90665b625c5805fcde0 100644 (file)
@@ -1,5 +1,5 @@
 import {
-       NEWLINE_SEPARATOR,
+       NEWLINE,
        SANDBOX_EMPTY_OUTPUT,
        SANDBOX_OUTPUT_MAX_CHARS,
        SANDBOX_TIMEOUT_MS_DEFAULT,
@@ -29,10 +29,10 @@ function formatReply(reply: SandboxReply): ToolExecutionResult {
                lines.push(`=> ${String(reply.result)}`);
        }
 
-       let content = lines.join(NEWLINE_SEPARATOR);
+       let content = lines.join(NEWLINE);
        if (!content) content = SANDBOX_EMPTY_OUTPUT;
        if (content.length > SANDBOX_OUTPUT_MAX_CHARS) {
-               content = `${content.slice(0, SANDBOX_OUTPUT_MAX_CHARS)}${NEWLINE_SEPARATOR}${SANDBOX_TRUNCATION_NOTICE}`;
+               content = `${content.slice(0, SANDBOX_OUTPUT_MAX_CHARS)}${NEWLINE}${SANDBOX_TRUNCATION_NOTICE}`;
        }
 
        return { content, isError: reply.error != null };
index 8f39f5209f8e6552677e9172de933eb5ae6b6dab..05cc42fd5f7c583772d6152be674be52a19794c1 100644 (file)
@@ -1,3 +1,6 @@
+import { base } from '$app/paths';
+import { getJsonHeaders } from '$lib/utils/api-headers';
+import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
 import { apiFetch } from '$lib/utils';
 import { API_TOOLS } from '$lib/constants';
 import { ToolResponseField } from '$lib/enums';
@@ -37,4 +40,91 @@ export class ToolsService {
 
                return { content: JSON.stringify(result), isError: false };
        }
+
+       /**
+        * Stream a built-in tool's output chunks from the server. The server
+        * `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
+        * events followed by a terminal `data: {"done": true}` (optionally with
+        * `error`). Yields the chunk string for each partial event.
+        *
+        * The terminal event's `error` field, if present, is yielded as a final
+        * synthetic chunk prefixed with an error marker so the accumulated content
+        * already carries the failure context for the caller.
+        *
+        * Throws synchronously if the server rejects the request (e.g. tool does
+        * not support streaming, or 4xx/5xx response). The HTTP fetch goes through
+        * a minimal text/event-stream reader since the chat SSE parser in
+        * chat.service.ts embeds extra resume logic that is unnecessary here.
+        */
+       static async *streamTool(
+               toolName: string,
+               params: Record<string, unknown>,
+               signal?: AbortSignal
+       ): AsyncGenerator<ToolStreamEvent> {
+               const headers = getJsonHeaders();
+               const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
+                       method: 'POST',
+                       headers,
+                       body: JSON.stringify({ tool: toolName, params, stream: true }),
+                       signal
+               });
+
+               if (!response.ok || !response.body) {
+                       const detail = await formatNonOkResponse(response);
+                       throw new Error(detail);
+               }
+
+               const iterator = parseSseJsonStream<ToolServerEvent>(response, signal);
+
+               while (true) {
+                       const next: IteratorResult<SseJsonEvent<ToolServerEvent>> = await iterator.next();
+                       if (next.done) return;
+                       const event = next.value.data;
+
+                       if (event.chunk !== undefined) {
+                               yield { chunk: event.chunk, done: false };
+                       }
+                       if (event.done) {
+                               yield { chunk: null, done: true, error: event.error };
+                               return;
+                       }
+               }
+       }
+}
+
+/**
+ * One event from streaming a tool's output.
+ *   - During execution: `chunk` is a non-empty text fragment, `done: false`.
+ *   - On terminal event: `done: true`, `error` populated if the call failed,
+ *     and `chunk` is null.
+ */
+export interface ToolStreamEvent {
+       chunk: string | null;
+       done: boolean;
+       error?: string;
+}
+
+/** Wire shape of one SSE event from `POST /tools?stream=true`. */
+interface ToolServerEvent {
+       chunk?: string;
+       done?: boolean;
+       error?: string;
+}
+
+async function formatNonOkResponse(response: Response): Promise<string> {
+       const status = `${response.status} ${response.statusText}`.trim();
+       try {
+               const errBody = (await response.clone().json()) as { error?: string; message?: string };
+               if (errBody?.error) return `${status}: ${errBody.error}`;
+               if (errBody?.message) return `${status}: ${errBody.message}`;
+       } catch (error) {
+               console.error('[tools] Non-JSON error response, falling back to raw text:', error);
+               try {
+                       const text = await response.text();
+                       if (text.trim()) return `${status}: ${text.trim()}`;
+               } catch (error) {
+                       console.error('[tools] Failed to read error response as text:', error);
+               }
+       }
+       return status || `HTTP ${response.status}`;
 }
index a1beb4029eb05c1f9d82786b8ab70aaf0e8a5dd6..4ced749eac9aa6dfc6aee375e1c207e7e0a240bc 100644 (file)
@@ -26,12 +26,12 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
 import { modelsStore } from '$lib/stores/models.svelte';
 import { toolsStore } from '$lib/stores/tools.svelte';
 import { permissionsStore } from '$lib/stores/permissions.svelte';
-import { ToolSource, ToolPermissionDecision } from '$lib/enums';
+import { BuiltInTool, ToolSource, ToolPermissionDecision } from '$lib/enums';
 import { SvelteMap } from 'svelte/reactivity';
 import { ToolsService } from '$lib/services/tools.service';
 import { SandboxService } from '$lib/services/sandbox.service';
 import { isAbortError } from '$lib/utils';
-import { DEFAULT_AGENTIC_CONFIG, NEWLINE_SEPARATOR } from '$lib/constants';
+import { DEFAULT_AGENTIC_CONFIG, NEWLINE } from '$lib/constants';
 import {
        IMAGE_MIME_TO_EXTENSION,
        DATA_URI_BASE64_REGEX,
@@ -86,7 +86,8 @@ function createDefaultSession(): AgenticSession {
                totalToolCalls: 0,
                lastError: null,
                streamingToolCall: null,
-               pendingPermissionRequest: null
+               pendingPermissionRequest: null,
+               executingToolCallId: null
        };
 }
 
@@ -187,23 +188,27 @@ class AgenticStore {
        }
 
        isRunning(conversationId: string): boolean {
-               return this.getSession(conversationId).isRunning;
+               return this._sessions.get(conversationId)?.isRunning ?? false;
        }
 
        currentTurn(conversationId: string): number {
-               return this.getSession(conversationId).currentTurn;
+               return this._sessions.get(conversationId)?.currentTurn ?? 0;
        }
 
        totalToolCalls(conversationId: string): number {
-               return this.getSession(conversationId).totalToolCalls;
+               return this._sessions.get(conversationId)?.totalToolCalls ?? 0;
        }
 
        lastError(conversationId: string): Error | null {
-               return this.getSession(conversationId).lastError;
+               return this._sessions.get(conversationId)?.lastError ?? null;
        }
 
        streamingToolCall(conversationId: string): { name: string; arguments: string } | null {
-               return this.getSession(conversationId).streamingToolCall;
+               return this._sessions.get(conversationId)?.streamingToolCall ?? null;
+       }
+
+       executingToolCallId(conversationId: string): string | null {
+               return this._sessions.get(conversationId)?.executingToolCallId ?? null;
        }
 
        pendingPermissionRequest(
@@ -489,6 +494,7 @@ class AgenticStore {
                        onCompletionId,
                        onAssistantTurnComplete,
                        createToolResultMessage,
+                       updateToolResultMessage,
                        createAssistantMessage,
                        onFlowComplete,
                        onTimings,
@@ -574,6 +580,7 @@ class AgenticStore {
                                                onToolCallChunk: (serialized: string) => {
                                                        try {
                                                                turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
+
                                                                onToolCallsStreaming?.(turnToolCalls);
 
                                                                if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
@@ -651,6 +658,21 @@ class AgenticStore {
                                throw normalizedError;
                        }
 
+                       // If the abort landed while ChatService.sendMessage was still resolving, the
+                       // outer catch above never fires because ChatService swallows the AbortError
+                       // and returns normally. Bail out here so a half-received tool_call (truncated
+                       // arguments JSON) is not persisted as if it were complete.
+                       if (signal?.aborted) {
+                               await onAssistantTurnComplete?.(
+                                       turnContent,
+                                       turnReasoningContent || undefined,
+                                       this.buildFinalTimings(capturedTimings, agenticTimings),
+                                       undefined
+                               );
+                               onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
+                               return;
+                       }
+
                        // === Steering check: if a user message was queued during this turn, exit the flow.
                        // The caller (chatStore) will consume the pending message and re-send it normally.
                        if (this._steeringMessages.has(conversationId)) {
@@ -768,15 +790,49 @@ class AgenticStore {
                                const toolStartTime = performance.now();
                                const toolSource = toolsStore.getToolSource(toolName);
 
-                               let result: string;
+                               let result = '';
                                let toolSuccess = true;
+                               let createdToolResultMessageId: string | null = null;
+
+                               // Streaming tools (currently only exec_shell_command): mark
+                               // the session so the matching renderer can switch to live mode.
+                               // Cleared unconditionally below.
+                               this.updateSession(conversationId, { executingToolCallId: toolCall.id });
 
                                if (permission === ToolPermissionDecision.DENY) {
                                        result = 'Tool execution was denied by the user.';
                                        toolSuccess = false;
                                } else {
                                        try {
-                                               if (toolSource === ToolSource.BUILTIN) {
+                                               if (
+                                                       toolSource === ToolSource.BUILTIN &&
+                                                       toolName === BuiltInTool.EXEC_SHELL_COMMAND &&
+                                                       createToolResultMessage &&
+                                                       updateToolResultMessage
+                                               ) {
+                                                       const args = this.parseToolArguments(toolCall.function.arguments);
+                                                       const msg = await createToolResultMessage(toolCall.id, '');
+                                                       createdToolResultMessageId = msg.id;
+
+                                                       let accumulated = '';
+                                                       for await (const ev of ToolsService.streamTool(toolName, args, signal)) {
+                                                               if (ev.chunk !== null) {
+                                                                       accumulated += ev.chunk;
+                                                                       await updateToolResultMessage(msg.id, accumulated);
+                                                               }
+                                                               if (ev.done) {
+                                                                       if (ev.error) {
+                                                                               accumulated = accumulated
+                                                                                       ? `${accumulated}\nError: ${ev.error}`
+                                                                                       : `Error: ${ev.error}`;
+                                                                               await updateToolResultMessage(msg.id, accumulated);
+                                                                               toolSuccess = false;
+                                                                       }
+                                                                       break;
+                                                               }
+                                                       }
+                                                       result = accumulated;
+                                               } else if (toolSource === ToolSource.BUILTIN) {
                                                        const args = this.parseToolArguments(toolCall.function.arguments);
                                                        const executionResult = await ToolsService.executeTool(toolName, args, signal);
 
@@ -801,14 +857,24 @@ class AgenticStore {
                                                }
                                        } catch (error) {
                                                if (isAbortError(error)) {
+                                                       this.updateSession(conversationId, { executingToolCallId: null });
                                                        onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
                                                        return;
                                                }
-                                               result = `Error: ${error instanceof Error ? error.message : String(error)}`;
+                                               // Carry the partial stream contents already mirrored to the UI -
+                                               // they show up as live output even if the stream broke off mid-run.
+                                               result = result
+                                                       ? `${result}\nError: ${error instanceof Error ? error.message : String(error)}`
+                                                       : `Error: ${error instanceof Error ? error.message : String(error)}`;
                                                toolSuccess = false;
+                                               if (createdToolResultMessageId && updateToolResultMessage) {
+                                                       await updateToolResultMessage(createdToolResultMessageId, result);
+                                               }
                                        }
                                }
 
+                               this.updateSession(conversationId, { executingToolCallId: null });
+
                                const toolDurationMs = performance.now() - toolStartTime;
                                const toolTiming: ChatMessageToolCallTiming = {
                                        name: toolCall.function.name,
@@ -829,9 +895,19 @@ class AgenticStore {
 
                                const { cleanedResult, attachments } = this.extractBase64Attachments(result);
 
-                               // Create the tool result message in the DB
+                               // For streaming tools the result message was created empty
+                               // at the start of execution and updated in place as chunks
+                               // arrived via updateToolResultMessage. Skip the second
+                               // create call - just attach any base64 attachments found in
+                               // the final accumulator (rare, since chunks usually don't
+                               // carry image data URIs) and emit the attachments callback.
                                let toolResultMessage: DatabaseMessage | undefined;
-                               if (createToolResultMessage) {
+                               if (createdToolResultMessageId) {
+                                       toolResultMessage = { id: createdToolResultMessageId } as DatabaseMessage;
+                                       if (attachments.length > 0 && updateToolResultMessage) {
+                                               await updateToolResultMessage(createdToolResultMessageId, cleanedResult, attachments);
+                                       }
+                               } else if (createToolResultMessage) {
                                        toolResultMessage = await createToolResultMessage(
                                                toolCall.id,
                                                cleanedResult,
@@ -926,7 +1002,7 @@ class AgenticStore {
                        return { cleanedResult: result, attachments: [] };
                }
 
-               const lines = result.split(NEWLINE_SEPARATOR);
+               const lines = result.split(NEWLINE);
                const attachments: DatabaseMessageExtra[] = [];
                let attachmentIndex = 0;
 
@@ -957,7 +1033,7 @@ class AgenticStore {
                        return line;
                });
 
-               return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments };
+               return { cleanedResult: cleanedLines.join(NEWLINE), attachments };
        }
 
        private buildAttachmentName(mimeType: string, index: number): string {
@@ -1032,3 +1108,7 @@ export function agenticClearSteeringMessage(conversationId: string) {
 export function agenticIsAnyRunning() {
        return agenticStore.isAnyRunning;
 }
+
+export function agenticExecutingToolCallId(conversationId: string) {
+       return agenticStore.executingToolCallId(conversationId);
+}
index 0a496f2c77aa286fc57ff9fcfd9d32bad6699ad4..40c9cf71fac0a0b61b3741e32ba27b05fe9eddc3 100644 (file)
@@ -562,8 +562,14 @@ class ChatStore {
         * Abort the current agentic flow signal without clearing loading state.
         * Used by "Send immediately" to force the agentic loop to exit so that
         * the pending steering message can be re-sent.
+        *
+        * Any tool calls captured mid-stream are dropped before the abort so the
+        * pending message (or a manual follow-up) does not re-send a half-received
+        * tool call with invalid JSON arguments to the server. Mirrors what the
+        * Stop button already does through stopGenerationForChat.
         */
-       abortCurrentFlow(convId: string): void {
+       async abortCurrentFlow(convId: string): Promise<void> {
+               await this.savePartialResponseIfNeeded(convId);
                const c = this.abortControllers.get(convId);
                if (c) {
                        c.abort();
@@ -1255,6 +1261,28 @@ class ChatStore {
                                lastCreatedInFlow = msg.id;
                                return msg;
                        },
+                       updateToolResultMessage: async (
+                               messageId: string,
+                               content: string,
+                               extras?: DatabaseMessageExtra[]
+                       ) => {
+                               // Persist latest content + merged extras; mirror into the active
+                               // store so the chat view sees live updates for streaming tools
+                               // (e.g. exec_shell_command). The existing tool message node
+                               // pointer stays put - the renderer is already scoped to it.
+                               const updates: Partial<DatabaseMessage> = { content };
+                               if (extras) {
+                                       const idx = conversationsStore.findMessageIndex(messageId);
+                                       const existing = idx >= 0 ? (conversationsStore.activeMessages[idx]?.extra ?? []) : [];
+                                       const merged = [...existing, ...extras];
+                                       updates.extra = merged;
+                               }
+                               if (conversationsStore.activeConversation?.id === convId) {
+                                       const idx = conversationsStore.findMessageIndex(messageId);
+                                       if (idx >= 0) conversationsStore.updateMessageAtIndex(idx, updates);
+                               }
+                               await DatabaseService.updateMessage(messageId, updates);
+                       },
                        createAssistantMessage: async () => {
                                // Reset streaming state for new message
                                streamedContent = '';
@@ -1505,21 +1533,27 @@ class ChatStore {
 
                const partialContent = streamingState.response;
                const partialReasoning = lastMessage.reasoningContent || '';
+               // snapshot the streamed tool calls before clearing so we still know whether
+               // anything was captured when deciding to skip the DB write below
+               const hadPartialToolCalls = !!lastMessage.toolCalls?.trim();
 
-               // nothing to persist when both content and reasoning are empty (e.g. stop before any token)
-               if (!partialContent.trim() && !partialReasoning.trim()) return;
+               // nothing to persist when content, reasoning, and streamed tool calls are all empty
+               // (e.g. stop before any token). otherwise drop the partial tool call and write whatever
+               // was streamed: incomplete arguments (truncated JSON, missing closing quote) would
+               // otherwise be re-sent to the server on the next turn and rejected.
+               if (!partialContent.trim() && !partialReasoning.trim() && !hadPartialToolCalls) return;
 
                try {
                        const updateData: {
-                               content: string;
+                               content?: string;
                                reasoningContent?: string;
+                               toolCalls?: string;
                                timings?: ChatMessageTimings;
                        } = {
-                               content: partialContent
+                               toolCalls: ''
                        };
-                       if (partialReasoning) {
-                               updateData.reasoningContent = partialReasoning;
-                       }
+                       if (partialContent.trim()) updateData.content = partialContent;
+                       if (partialReasoning.trim()) updateData.reasoningContent = partialReasoning;
                        const lastKnownState = this.getProcessingState(conversationId);
                        if (lastKnownState) {
                                updateData.timings = {
@@ -1535,9 +1569,14 @@ class ChatStore {
                        }
                        await DatabaseService.updateMessage(lastMessage.id, updateData);
                        lastMessage.content = partialContent;
+                       // mirror the drop into the in-memory message so the next request sent via
+                       // sendMessage (queued pending, Send immediately, or manual follow-up) reads
+                       // the cleared value, not whatever the streaming widget had been showing
+                       lastMessage.toolCalls = '';
                        if (updateData.timings) lastMessage.timings = updateData.timings;
                } catch (error) {
                        lastMessage.content = partialContent;
+                       lastMessage.toolCalls = '';
                        console.error('Failed to save partial response:', error);
                }
        }
index 18347fb7521a84040c59152b1ea2f8bbc9ca8903..81fb86d972d47d3570c1f48ee03bff4e19287b43 100644 (file)
@@ -16,7 +16,7 @@ import {
        MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
        MCP_RESOURCE_CACHE_MAX_ENTRIES,
        MCP_RESOURCE_CACHE_TTL_MS,
-       NEWLINE_SEPARATOR,
+       NEWLINE,
        RESOURCE_UNKNOWN_TYPE,
        BINARY_CONTENT_LABEL
 } from '$lib/constants';
@@ -587,7 +587,7 @@ class MCPResourceStore {
                                        name: resourceName,
                                        uri: attachment.resource.uri,
                                        serverName: attachment.resource.serverName,
-                                       content: contentParts.join(NEWLINE_SEPARATOR),
+                                       content: contentParts.join(NEWLINE),
                                        mimeType: attachment.resource.mimeType
                                });
                        }
index 4f05ad13ec38226f03619aafc7d2c2cd315b173f..e322ecbffbb64ccb761312d611c2b0f7bd42759e 100644 (file)
@@ -205,6 +205,9 @@ class MCPStore {
                server: MCPServerSettingsEntry,
                perChatOverrides?: McpServerOverride[]
        ): boolean {
+               // Per-chat overrides win when present; missing entries inherit the
+               // server's own `enabled` flag so partial override lists are not all
+               // treated as disabled.
                const override = perChatOverrides?.find((o) => o.serverId === server.id);
                return override?.enabled ?? server.enabled;
        }
@@ -348,7 +351,6 @@ class MCPStore {
        }
 
        clearHealthCheck(serverId: string): void {
-               // eslint-disable-next-line @typescript-eslint/no-unused-vars
                const { [serverId]: _removed, ...rest } = this._healthChecks;
                this._healthChecks = rest;
        }
@@ -939,6 +941,41 @@ class MCPStore {
                return this.toolsIndex.get(toolName);
        }
 
+       /**
+        * Resolve which configured MCP server owns a given tool name. Looks at
+        * active connections first (fast path), then falls back to per-server
+        * health-check data so server-side MCP proxies (where llama-server
+        * executes MCP tools but the browser does not hold a direct connection)
+        * still resolve tool names to their owning server.
+        */
+       findServerForTool(toolName: string): string | undefined {
+               const fromIndex = this.toolsIndex.get(toolName);
+               if (fromIndex) return fromIndex;
+
+               for (const server of this.getServers()) {
+                       const health = this._healthChecks[server.id];
+                       if (!health || health.status !== HealthCheckStatus.SUCCESS) continue;
+                       if (health.tools.some((tool) => tool.name === toolName)) {
+                               return server.id;
+                       }
+               }
+
+               return undefined;
+       }
+
+       /**
+        * Resolve the favicon URL for an MCP server by one of its tool names.
+        * Returns `null` if the tool is not provided by any configured MCP server,
+        * or if the owning server has no icon to show.
+        * Pair with {@link getServerFavicon} for direct server-id lookup.
+        */
+       getServerFaviconForTool(toolName: string | undefined): string | null {
+               if (!toolName) return null;
+               const serverId = this.findServerForTool(toolName);
+               if (!serverId) return null;
+               return this.getServerFavicon(serverId);
+       }
+
        hasPromptsSupport(): boolean {
                for (const connection of this.connections.values()) {
                        if (connection.serverCapabilities?.prompts) {
@@ -955,44 +992,27 @@ class MCPStore {
         * the user actually sends a message or uses prompts.
         * @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
         *                          If provided (even empty array), only checks enabled servers.
-        *                          If undefined, checks all servers with successful health checks.
+        *                          If undefined, falls back to each server's own `enabled` flag.
         */
        hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean {
-               // If perChatOverrides is provided (even empty array), filter by enabled servers
+               let enabledServerIds: Set<string>;
+
                if (perChatOverrides !== undefined) {
-                       const enabledServerIds = new Set(
-                               perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
+                       enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
+               } else {
+                       enabledServerIds = new Set(
+                               this.getServers()
+                                       .filter((s) => s.enabled)
+                                       .map((s) => s.id)
                        );
+               }
 
-                       // No enabled servers = no capability
-                       if (enabledServerIds.size === 0) {
-                               return false;
-                       }
-
-                       // Check health check states for enabled servers with prompts capability
-                       for (const [serverId, state] of Object.entries(this._healthChecks)) {
-                               if (!enabledServerIds.has(serverId)) continue;
-                               if (
-                                       state.status === HealthCheckStatus.SUCCESS &&
-                                       state.capabilities?.server?.prompts !== undefined
-                               ) {
-                                       return true;
-                               }
-                       }
-
-                       // Also check active connections as fallback
-                       for (const [serverName, connection] of this.connections) {
-                               if (!enabledServerIds.has(serverName)) continue;
-                               if (connection.serverCapabilities?.prompts) {
-                                       return true;
-                               }
-                       }
-
+               if (enabledServerIds.size === 0) {
                        return false;
                }
 
-               // No overrides provided - check all servers (global mode)
-               for (const state of Object.values(this._healthChecks)) {
+               for (const [serverId, state] of Object.entries(this._healthChecks)) {
+                       if (!enabledServerIds.has(serverId)) continue;
                        if (
                                state.status === HealthCheckStatus.SUCCESS &&
                                state.capabilities?.server?.prompts !== undefined
@@ -1001,7 +1021,8 @@ class MCPStore {
                        }
                }
 
-               for (const connection of this.connections.values()) {
+               for (const [serverName, connection] of this.connections) {
+                       if (!enabledServerIds.has(serverName)) continue;
                        if (connection.serverCapabilities?.prompts) {
                                return true;
                        }
@@ -1518,43 +1539,26 @@ class MCPStore {
         * the user actually sends a message or uses prompts.
         * @param perChatOverrides - Per-chat server overrides to filter by enabled servers.
         *                          If provided (even empty array), only checks enabled servers.
-        *                          If undefined, checks all servers with successful health checks.
+        *                          If undefined, falls back to each server's own `enabled` flag.
         */
        hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean {
-               // If perChatOverrides is provided (even empty array), filter by enabled servers
+               let enabledServerIds: Set<string>;
+
                if (perChatOverrides !== undefined) {
-                       const enabledServerIds = new Set(
-                               perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId)
+                       enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
+               } else {
+                       enabledServerIds = new Set(
+                               this.getServers()
+                                       .filter((s) => s.enabled)
+                                       .map((s) => s.id)
                        );
-                       // No enabled servers = no capability
-                       if (enabledServerIds.size === 0) {
-                               return false;
-                       }
-
-                       // Check health check states for enabled servers with resources capability
-                       for (const [serverId, state] of Object.entries(this._healthChecks)) {
-                               if (!enabledServerIds.has(serverId)) continue;
-                               if (
-                                       state.status === HealthCheckStatus.SUCCESS &&
-                                       state.capabilities?.server?.resources !== undefined
-                               ) {
-                                       return true;
-                               }
-                       }
-
-                       // Also check active connections as fallback
-                       for (const [serverName, connection] of this.connections) {
-                               if (!enabledServerIds.has(serverName)) continue;
-                               if (MCPService.supportsResources(connection)) {
-                                       return true;
-                               }
-                       }
-
+               }
+               if (enabledServerIds.size === 0) {
                        return false;
                }
 
-               // No overrides provided - check all servers (global mode)
-               for (const state of Object.values(this._healthChecks)) {
+               for (const [serverId, state] of Object.entries(this._healthChecks)) {
+                       if (!enabledServerIds.has(serverId)) continue;
                        if (
                                state.status === HealthCheckStatus.SUCCESS &&
                                state.capabilities?.server?.resources !== undefined
@@ -1563,7 +1567,8 @@ class MCPStore {
                        }
                }
 
-               for (const connection of this.connections.values()) {
+               for (const [serverName, connection] of this.connections) {
+                       if (!enabledServerIds.has(serverName)) continue;
                        if (MCPService.supportsResources(connection)) {
                                return true;
                        }
@@ -1573,14 +1578,20 @@ class MCPStore {
        }
 
        /**
-        * Get list of servers that support resources.
+        * Get list of enabled servers that support resources.
         * Checks active connections first, then health check state as fallback.
         */
        getServersWithResources(): string[] {
+               const enabledServerIds = new Set(
+                       this.getServers()
+                               .filter((s) => s.enabled)
+                               .map((s) => s.id)
+               );
                const servers: string[] = [];
 
                // Check active connections
                for (const [name, connection] of this.connections) {
+                       if (!enabledServerIds.has(name)) continue;
                        if (MCPService.supportsResources(connection) && !servers.includes(name)) {
                                servers.push(name);
                        }
@@ -1588,6 +1599,7 @@ class MCPStore {
 
                // Also check health check states for servers not yet connected
                for (const [serverId, state] of Object.entries(this._healthChecks)) {
+                       if (!enabledServerIds.has(serverId)) continue;
                        if (
                                !servers.includes(serverId) &&
                                state.status === HealthCheckStatus.SUCCESS &&
index 0b5a8ef6cd29c0a7f27e49cf9547e5c0b86a0be6..e235db78b76434f43255be5650ca5faea8a6496c 100644 (file)
@@ -69,6 +69,11 @@ export interface AgenticSession {
        lastError: Error | null;
        streamingToolCall: { name: string; arguments: string } | null;
        pendingPermissionRequest: { toolName: string; serverLabel: string } | null;
+       /** ID of the tool call whose output is currently being streamed back
+        *  (e.g. exec_shell_command outputting to /tools?stream=true). Lets the
+        *  matching tool renderer flip into live-update mode while chunks
+        *  arrive; cleared when the tool's terminal event lands. */
+       executingToolCallId: string | null;
 }
 
 /**
@@ -106,6 +111,15 @@ export interface AgenticFlowCallbacks {
                content: string,
                extras?: DatabaseMessageExtra[]
        ) => Promise<DatabaseMessage>;
+       /** Update an already-created tool result message. Used while a streaming
+        *  tool (e.g. exec_shell_command) accumulates output chunks before its
+        *  terminal event; the same message is rewritten in place so the chat UI
+        *  sees the partial output live. */
+       updateToolResultMessage?: (
+               messageId: string,
+               content: string,
+               extras?: DatabaseMessageExtra[]
+       ) => Promise<void>;
        /** Create a new assistant message for the next agentic turn */
        createAssistantMessage?: () => Promise<DatabaseMessage>;
        /** Entire agentic flow is complete */
index b2158807d4d7302d3282a9740f801b0ebac0b9d4..0d6063519820fb5806b1c282a9e7e89e37106c40 100644 (file)
@@ -110,6 +110,11 @@ export interface ChatStreamCallbacks {
                content: string,
                extras?: DatabaseMessageExtra[]
        ) => Promise<DatabaseMessage>;
+       updateToolResultMessage?: (
+               messageId: string,
+               content: string,
+               extras?: DatabaseMessageExtra[]
+       ) => Promise<void>;
        createAssistantMessage?: () => Promise<DatabaseMessage>;
        onFlowComplete?: (timings?: ChatMessageTimings) => void;
        onError?: (error: Error) => void;
index 91ceb72e7bd951bb1300de5c2d738c466bd3669d..408ac0cbdcbf5963c0bdeaf15692e82cb4a7ac3e 100644 (file)
@@ -128,6 +128,7 @@ export type {
        MCPClientConfig,
        MCPServerSettingsEntry,
        MCPServerDisplayInfo,
+       RecommendedMCPServer,
        MCPToolCall,
        OpenAIToolDefinition,
        ServerStatus,
index 2666394a029febba5d573260051cebaf5f1c1a9b..6b875e4cebe9d22db6e7c751334042cf9fbcd319 100644 (file)
@@ -224,6 +224,26 @@ export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
        useProxy?: boolean;
 };
 
+/**
+ * Pre-defined recommended MCP server shown to the user in picker UIs.
+ * Intentionally minimal: rendering must never trigger a network call to
+ * the upstream server until the user explicitly adds it.
+ */
+export interface RecommendedMCPServer {
+       id: string;
+       name: string;
+       description: string;
+       url: string;
+       /** Local asset path (e.g. "/recommended-mcp/exa.ico") for the card favicon. Used regardless of theme. */
+       iconUrl?: string;
+       /** Light-theme favicon (e.g. "/recommended-mcp/github-light.png"). Preferred over `iconUrl` when paired with `iconUrlDark`. */
+       iconUrlLight?: string;
+       /** Dark-theme favicon (e.g. "/recommended-mcp/github-dark.png"). Preferred over `iconUrl` when paired with `iconUrlLight`. */
+       iconUrlDark?: string;
+       /** When true, picking this recommendation also flips the form's "Authorization" switch on so the user can paste a Bearer token right away. */
+       needsAuthorization?: boolean;
+}
+
 export interface MCPHostManagerConfig {
        servers: MCPClientConfig['servers'];
        clientInfo?: Implementation;
index 9c787cd958f795cc7f8765ec0d4335b4d16e5391..9b4af4a58f7d6a560208dc951c798f22176f4173 100644 (file)
@@ -29,6 +29,7 @@ export interface SettingsEntry {
        options?: Array<{ value: string; label: string; icon: Component }>;
        isExperimental?: boolean;
        isPositiveInteger?: boolean;
+       dependsOn?: string;
        sync?: {
                serverKey: string;
                paramType: SyncableParameterType;
@@ -49,6 +50,7 @@ export interface SettingsFieldConfig {
        type: SettingsFieldType;
        isExperimental?: boolean;
        isPositiveInteger?: boolean;
+       dependsOn?: string;
        help?: string;
        options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
 }
index d19f03434e6bc3b49722dd865f04645abb4a4bfc..aa8c287c3ab56e207c6dcbfad4f6f8a8eac73672 100644 (file)
@@ -1,12 +1,32 @@
-import { AgenticSectionType, ContinueIntentKind, MessageRole } from '$lib/enums';
-import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
+import {
+       AgenticSectionType,
+       AttachmentType,
+       ContinueIntentKind,
+       MessageRole,
+       ToolResultKind
+} from '$lib/enums';
+import {
+       ATTACHMENT_SAVED_REGEX,
+       MARKDOWN_ATX_HEADING_REGEX,
+       MARKDOWN_BOLD_REGEX,
+       MARKDOWN_BLOCKQUOTE_REGEX,
+       MARKDOWN_CODE_FENCE_REGEX,
+       MARKDOWN_LINK_REGEX,
+       MARKDOWN_LIST_BULLET_REGEX,
+       MARKDOWN_LIST_NUMBERED_REGEX,
+       MARKDOWN_TABLE_SEPARATOR_REGEX,
+       NEWLINE,
+       REASONING_TAGS,
+       SEARCH_SUMMARY_SEPARATOR,
+       SEARCH_SUMMARY_TOTAL_REGEX,
+       TOOL_RESULT_JSON_OPEN_REGEX
+} from '$lib/constants';
 import type { ApiChatCompletionToolCall } from '$lib/types/api';
 import type {
        DatabaseMessage,
        DatabaseMessageExtra,
        DatabaseMessageExtraImageFile
 } from '$lib/types/database';
-import { AttachmentType } from '$lib/enums';
 
 /**
  * Represents a parsed section of agentic content for display
@@ -18,6 +38,11 @@ export interface AgenticSection {
        toolArgs?: string;
        toolResult?: string;
        toolResultExtras?: DatabaseMessageExtra[];
+       /** ID of the model-side tool call (matches tool_calls[i].id). Lets
+        *  downstream consumers correlate a section with the agentic loop's
+        *  currently-executing tool, e.g. to drive live-streaming UI state
+        *  by matching against agenticStore.executingToolCallId. */
+       toolCallId?: string;
        wasInterrupted?: boolean;
 }
 
@@ -81,7 +106,8 @@ function deriveSingleTurnSections(
                        toolName: tc.function?.name,
                        toolArgs: tc.function?.arguments,
                        toolResult: resultMsg?.content,
-                       toolResultExtras: resultMsg?.extra
+                       toolResultExtras: resultMsg?.extra,
+                       toolCallId: tc.id
                });
        }
 
@@ -93,7 +119,8 @@ function deriveSingleTurnSections(
                        type: AgenticSectionType.TOOL_CALL_STREAMING,
                        content: '',
                        toolName: tc.function?.name,
-                       toolArgs: tc.function?.arguments
+                       toolArgs: tc.function?.arguments,
+                       toolCallId: tc.id
                });
        }
 
@@ -158,6 +185,52 @@ export function deriveAgenticSections(
        return sections;
 }
 
+/**
+ * Build the raw text representation shown in the "raw output" view of an
+ * assistant message. Each section is formatted as it would appear in the
+ * model-facing transcript, joined by blank lines.
+ */
+export function buildAssistantRawOutput(sections: AgenticSection[]): string {
+       const parts: string[] = [];
+
+       for (const section of sections) {
+               switch (section.type) {
+                       case AgenticSectionType.REASONING:
+                       case AgenticSectionType.REASONING_PENDING:
+                               parts.push(`${REASONING_TAGS.START}${NEWLINE}${section.content}${REASONING_TAGS.END}`);
+                               break;
+
+                       case AgenticSectionType.TEXT:
+                               parts.push(section.content);
+                               break;
+
+                       case AgenticSectionType.TOOL_CALL:
+                       case AgenticSectionType.TOOL_CALL_PENDING:
+                       case AgenticSectionType.TOOL_CALL_STREAMING: {
+                               const callObj: Record<string, unknown> = { name: section.toolName };
+
+                               if (section.toolArgs) {
+                                       try {
+                                               callObj.arguments = JSON.parse(section.toolArgs);
+                                       } catch {
+                                               callObj.arguments = section.toolArgs;
+                                       }
+                               }
+
+                               parts.push(JSON.stringify(callObj, null, 2));
+
+                               if (section.toolResult) {
+                                       parts.push(`${NEWLINE}${section.toolResult}`);
+                               }
+
+                               break;
+                       }
+               }
+       }
+
+       return parts.join(`${NEWLINE}${NEWLINE}`);
+}
+
 /**
  * Collect consecutive tool messages starting at `startIndex`.
  */
@@ -175,6 +248,39 @@ function collectToolMessages(messages: DatabaseMessage[], startIndex: number): D
        return result;
 }
 
+/**
+ * Split a tool-result blob into a list and an optional "Total matches: N"
+ * summary. Both file-glob and grep tools emit this format on the server:
+ *
+ *   <matches>
+ *   ---
+ *   Total matches: 42
+ *
+ * Returns the lines and exposes a callback for capturing the total so each
+ * caller can stash it on its own meta type without taking a return-tuple.
+ */
+export function splitSearchSummaryList(
+       text: string,
+       captureTotal: (n: number) => void
+): { lines: string[] } {
+       const separatorIndex = text.indexOf(SEARCH_SUMMARY_SEPARATOR);
+       const matchesText = separatorIndex === -1 ? text : text.slice(0, separatorIndex);
+       const summaryText =
+               separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY_SEPARATOR.length);
+
+       const totalMatch = summaryText.match(SEARCH_SUMMARY_TOTAL_REGEX);
+       if (totalMatch) {
+               captureTotal(parseInt(totalMatch[1], 10));
+       }
+
+       const lines = matchesText
+               .split(NEWLINE)
+               .map((line) => line.trim())
+               .filter((line) => line.length > 0);
+
+       return { lines };
+}
+
 /**
  * Parse tool result text into lines, matching image attachments by name.
  */
@@ -182,7 +288,7 @@ export function parseToolResultWithImages(
        toolResult: string,
        extras?: DatabaseMessageExtra[]
 ): ToolResultLine[] {
-       const lines = toolResult.split(NEWLINE_SEPARATOR);
+       const lines = toolResult.split(NEWLINE);
        return lines.map((line) => {
                const match = line.match(ATTACHMENT_SAVED_REGEX);
                if (!match || !extras) return { text: line };
@@ -197,6 +303,73 @@ export function parseToolResultWithImages(
        });
 }
 
+/**
+ * Pick a renderer tier for a tool's result content.
+ *
+ *   json     - trimmed content starts with `{` or `[` and parses cleanly.
+ *   markdown - content shows structural markdown markers (headers, code
+ *              fences, links, lists, blockquotes, tables) and should render
+ *              through MarkdownContent for proper formatting.
+ *   text     - everything else, rendered as plain text lines (with image
+ *              attachment resolution as a side effect).
+ */
+export function classifyToolResult(content: string | undefined): ToolResultKind {
+       if (!content) return ToolResultKind.TEXT;
+       const trimmed = content.trim();
+       if (!trimmed) return ToolResultKind.TEXT;
+
+       // Strongest signal: JSON object/array round-trips through JSON.parse.
+       if (TOOL_RESULT_JSON_OPEN_REGEX.test(trimmed)) {
+               try {
+                       JSON.parse(trimmed);
+                       return ToolResultKind.JSON;
+               } catch (error) {
+                       console.error('[agentic] tool result looked like JSON but failed to parse:', error);
+               }
+       }
+
+       if (looksLikeMarkdown(trimmed)) return ToolResultKind.MARKDOWN;
+
+       return ToolResultKind.TEXT;
+}
+
+/**
+ * Heuristic detector for "is this content a markdown document rather than
+ * plain text?". True when at least one well-known structural marker shows
+ * up - headers, code fences, links, bold, lists, blockquotes, tables.
+ * Each marker is specific enough that plain tool-output prose rarely
+ * trips it, but plain text starting with `# 5` will - acceptable false
+ * positive for the gain in formatting for tool results like search
+ * summaries that come back already-mardown.
+ */
+function looksLikeMarkdown(content: string): boolean {
+       // Code fences are unambiguous - triple backticks or tildes at line start.
+       if (MARKDOWN_CODE_FENCE_REGEX.test(content)) return true;
+
+       const lines = content.split(NEWLINE);
+
+       for (const line of lines) {
+               if (MARKDOWN_ATX_HEADING_REGEX.test(line)) return true;
+               if (MARKDOWN_BLOCKQUOTE_REGEX.test(line)) return true;
+               if (MARKDOWN_LIST_BULLET_REGEX.test(line)) return true;
+               if (MARKDOWN_LIST_NUMBERED_REGEX.test(line)) return true;
+       }
+
+       // Inline structural markers anywhere in the body.
+       if (MARKDOWN_LINK_REGEX.test(content)) return true;
+       if (MARKDOWN_BOLD_REGEX.test(content)) return true;
+
+       // Tables: a pipe-bearing header line followed by a separator row.
+       if (lines.length >= 2) {
+               const head = lines[0];
+               const sep = lines[1];
+
+               if (head.includes('|') && MARKDOWN_TABLE_SEPARATOR_REGEX.test(sep)) return true;
+       }
+
+       return false;
+}
+
 /**
  * Safely parse the toolCalls JSON string from a DatabaseMessage.
  */
index a2b70d492ab2c2e12ca67bd934b971a49bdedf9d..1aa15e09d310ae70850bb53f6680cd44528f91d3 100644 (file)
@@ -1,5 +1,10 @@
 import { config } from '$lib/stores/settings.svelte';
-import { CORS_PROXY_HEADER_PREFIX, REDACTED_HEADERS } from '$lib/constants';
+import {
+       AUTHORIZATION_HEADER,
+       BEARER_PREFIX,
+       CORS_PROXY_HEADER_PREFIX,
+       REDACTED_HEADERS
+} from '$lib/constants';
 import { redactValue } from './redact';
 
 /**
@@ -10,7 +15,7 @@ export function getAuthHeaders(): Record<string, string> {
        const currentConfig = config();
        const apiKey = currentConfig.apiKey?.toString().trim();
 
-       return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
+       return apiKey ? { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` } : {};
 }
 
 /**
index dbbf9a09b2b243adad44ac01b42db5c62b25dd67..6b37dd58c16e0efd27b99b8134fefa915b4479fb 100644 (file)
@@ -1,6 +1,7 @@
 import { base } from '$app/paths';
 import { error } from '@sveltejs/kit';
 import { browser } from '$app/environment';
+import { AUTHORIZATION_HEADER, BEARER_PREFIX } from '$lib/constants';
 import { config } from '$lib/stores/settings.svelte';
 
 /**
@@ -24,7 +25,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
        try {
                const headers: Record<string, string> = {
                        'Content-Type': 'application/json',
-                       Authorization: `Bearer ${apiKey}`
+                       [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}`
                };
 
                const response = await fetch(`${base}/props`, { headers });
index d83bc31af3f5c5537dd879eaa3e845c355d97e97..a29028c74147a29bfedd307110328fbca574a75f 100644 (file)
@@ -6,7 +6,9 @@ import {
        AMPERSAND_REGEX,
        LT_REGEX,
        GT_REGEX,
-       FENCE_PATTERN
+       FENCE_PATTERN,
+       TRIM_LEADING_PADDING_REGEX,
+       TRIM_TRAILING_PADDING_REGEX
 } from '$lib/constants';
 
 export interface IncompleteCodeBlock {
@@ -15,6 +17,19 @@ export interface IncompleteCodeBlock {
        openingIndex: number;
 }
 
+/**
+ * Strips empty lines (whitespace-only) from the start and end of code.
+ *
+ * Tool call payloads frequently arrive with surrounding whitespace from LLM
+ * formatting (`"\nfunction ...\n"`). Preserving those newlines makes hljs emit
+ * a leading/trailing empty line that `<pre>` then renders as a phantom row,
+ * pushing real content away from the box edge. The trim keeps the body intact
+ * so internal blank lines are still rendered as such.
+ */
+function trimCodePadding(code: string): string {
+       return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
+}
+
 /**
  * Highlights code using highlight.js
  * @param code - The code to highlight
@@ -24,24 +39,28 @@ export interface IncompleteCodeBlock {
 export function highlightCode(code: string, language: string): string {
        if (!code) return '';
 
+       const trimmed = trimCodePadding(code);
+
        try {
                const lang = language.toLowerCase();
                const isSupported = hljs.getLanguage(lang);
 
                if (isSupported) {
-                       return hljs.highlight(code, { language: lang }).value;
+                       return hljs.highlight(trimmed, { language: lang }).value;
                } else {
-                       return hljs.highlightAuto(code).value;
+                       return hljs.highlightAuto(trimmed).value;
                }
        } catch {
                // Fallback to escaped plain text
-               return code
+               return trimmed
                        .replace(AMPERSAND_REGEX, '&amp;')
                        .replace(LT_REGEX, '&lt;')
                        .replace(GT_REGEX, '&gt;');
        }
 }
 
+export { trimCodePadding };
+
 /**
  * Detects if markdown ends with an incomplete code block (opened but not closed).
  * Returns the code block info if found, null otherwise.
diff --git a/tools/ui/src/lib/utils/compute-line-diff.ts b/tools/ui/src/lib/utils/compute-line-diff.ts
new file mode 100644 (file)
index 0000000..cbfc68c
--- /dev/null
@@ -0,0 +1,105 @@
+/**
+ * Line-level unified diff for tool result rendering.
+ *
+ * Pure functions: no DOM, no Svelte, no highlight.js dependency. The
+ * returned `DiffLine[]` carries enough information both to render a
+ * custom diff block (per-entry kind/text) and to fold back into a
+ * unified-diff-format string for off-the-shelf highlighter languages
+ * (`renderUnifiedDiff`).
+ *
+ * Algorithm: LCS dynamic programming with a soft "remove before add"
+ * tiebreak so the resulting diff reads `(old -> new)` left to right.
+ * O(m*n) time/space which is fine for the handful of lines an
+ * `edit_file` snippet typically carries.
+ */
+
+import { DiffLineKind } from '$lib/enums';
+
+export interface DiffLine {
+       kind: DiffLineKind;
+       text: string;
+       /** 1-indexed line number in the OLD content. Undefined for `add` lines. */
+       oldLine?: number;
+       /** 1-indexed line number in the NEW content. Undefined for `remove` lines. */
+       newLine?: number;
+}
+
+export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
+       const oldLines = splitLines(oldText);
+       const newLines = splitLines(newText);
+
+       const m = oldLines.length;
+       const n = newLines.length;
+
+       if (m === 0 && n === 0) return [];
+       if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, text: t, newLine: k + 1 }));
+       if (n === 0)
+               return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, text: t, oldLine: k + 1 }));
+
+       const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+       for (let i = 1; i <= m; i++) {
+               for (let j = 1; j <= n; j++) {
+                       if (oldLines[i - 1] === newLines[j - 1]) {
+                               lcs[i][j] = lcs[i - 1][j - 1] + 1;
+                       } else {
+                               lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
+                       }
+               }
+       }
+
+       const result: DiffLine[] = [];
+       let i = m;
+       let j = n;
+       while (i > 0 && j > 0) {
+               if (oldLines[i - 1] === newLines[j - 1]) {
+                       result.push({
+                               kind: DiffLineKind.CONTEXT,
+                               text: oldLines[i - 1],
+                               oldLine: i,
+                               newLine: j
+                       });
+                       i--;
+                       j--;
+               } else if (lcs[i - 1][j] >= lcs[i][j - 1]) {
+                       result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
+                       i--;
+               } else {
+                       result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
+                       j--;
+               }
+       }
+       while (i > 0) {
+               result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
+               i--;
+       }
+       while (j > 0) {
+               result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
+               j--;
+       }
+
+       result.reverse();
+       return result;
+}
+
+/** Folds `DiffLine[]` into a unified-diff-format text (`` ` ``/`+`/`-` prefixes).
+ *  Pass to a diff-aware highlighter (e.g., SyntaxHighlightedCode with
+ *  `language="diff"`) for colorization.
+ */
+export function renderUnifiedDiff(lines: DiffLine[]): string {
+       if (lines.length === 0) return '';
+       return lines.map((l) => prefixFor(l.kind) + l.text).join('\n');
+}
+
+/** Column-1 marker for a `DiffLine`: ` `, `+`, or `-`. */
+export function prefixFor(kind: DiffLineKind): string {
+       if (kind === DiffLineKind.ADD) return '+';
+       if (kind === DiffLineKind.REMOVE) return '-';
+       return ' ';
+}
+
+function splitLines(text: string): string[] {
+       if (text === '') return [];
+       const parts = text.split('\n');
+       if (parts[parts.length - 1] === '') parts.pop();
+       return parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
+}
index de74ee8686d1bcf91e04392d48c77462e5eb3077..24a2c1c94c1899f11bf45ad0c864664861c1a6f5 100644 (file)
@@ -3,11 +3,7 @@ import {
        SECONDS_PER_MINUTE,
        SECONDS_PER_HOUR,
        SHORT_DURATION_THRESHOLD,
-       MEDIUM_DURATION_THRESHOLD,
-       MAX_PREVIEW_LENGTH,
-       STRIP_MARKDOWN_INLINE_REGEX,
-       STRIP_MARKDOWN_CAPTURE_PATTERNS,
-       NEWLINE_SEPARATOR
+       MEDIUM_DURATION_THRESHOLD
 } from '$lib/constants';
 
 /**
@@ -155,33 +151,3 @@ export function formatAttachmentText(
        const header = extra ? `${name} (${extra})` : name;
        return `\n\n--- ${label}: ${header} ---\n${content}`;
 }
-
-export function formatReasoningPreview(content: string): { preview: string; overflow: number } {
-       if (!content) return { preview: '', overflow: 0 };
-
-       const lines = content.split(NEWLINE_SEPARATOR);
-       let lastLine = '';
-
-       for (let i = lines.length - 1; i >= 0; i--) {
-               let cleaned = lines[i].trim();
-               if (!cleaned) continue;
-
-               cleaned = cleaned.replace(STRIP_MARKDOWN_INLINE_REGEX, '');
-               for (const [pattern, replacement] of STRIP_MARKDOWN_CAPTURE_PATTERNS) {
-                       cleaned = cleaned.replace(pattern, replacement);
-               }
-
-               if (cleaned.length > 0) {
-                       lastLine = cleaned;
-                       break;
-               }
-       }
-
-       const fullLength = lastLine.length;
-       const overflow = Math.max(0, fullLength - MAX_PREVIEW_LENGTH);
-       if (fullLength > MAX_PREVIEW_LENGTH) {
-               lastLine = lastLine.slice(0, MAX_PREVIEW_LENGTH) + '...';
-       }
-
-       return { preview: lastLine, overflow };
-}
index 8474691acdfe3ba6dc92ba223bf3fec61f61215a..f829793f1c7cad4996cacec797e8abcdb62443b5 100644 (file)
@@ -30,7 +30,12 @@ export {
 } from './branching';
 
 // Code
-export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
+export {
+       highlightCode,
+       detectIncompleteCodeBlock,
+       trimCodePadding,
+       type IncompleteCodeBlock
+} from './code';
 
 // Config helpers
 export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
@@ -39,7 +44,7 @@ export { setConfigValue, getConfigValue, configToParameterRecord } from './confi
 export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
 
 // URL utilities
-export { extractRootDomain, sanitizeExternalUrl } from './url';
+export { extractRootDomain, sanitizeExternalUrl, canonicalizeServerUrl } from './url';
 
 // Progress helpers
 export { modelLoadFraction, modelLoadProgressText } from './progress';
@@ -76,8 +81,7 @@ export {
        formatJsonPretty,
        formatTime,
        formatPerformanceTime,
-       formatAttachmentText,
-       formatReasoningPreview
+       formatAttachmentText
 } from './formatters';
 
 // IME utilities
@@ -117,6 +121,10 @@ export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
 // Image error fallback utilities
 export { getImageErrorFallbackHtml } from './image-error-fallback';
 
+// SSE-with-JSON stream iterator (used by built-in tool streaming, decoupled
+// from chat.service.ts which embeds its own SSE parser for resume support)
+export { parseSseJsonStream, type SseJsonEvent } from './sse';
+
 // MCP utilities
 export {
        detectMcpTransportFromUrl,
@@ -153,12 +161,39 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
 // Agentic content utilities (structured section derivation)
 export {
        deriveAgenticSections,
+       buildAssistantRawOutput,
        parseToolResultWithImages,
+       splitSearchSummaryList,
        hasAgenticContent,
+       classifyToolResult,
        type AgenticSection,
        type ToolResultLine
 } from './agentic';
 
+// Line-level unified diff for tool result rendering (`edit_file` block)
+export { computeLineDiff, prefixFor, renderUnifiedDiff, type DiffLine } from './compute-line-diff';
+
+// Partial-incremental JSON parser for streaming tool arguments
+export { parsePartialJsonArgs } from './parse-partial-json-args';
+
+// `exec_shell_command` result parsing
+export { parseExecShellCommandError } from './parse-exec-shell-error';
+export {
+       parseExecShellCommandExitStatus,
+       isExitCodeSummaryLine,
+       type ExecShellExitStatus
+} from './parse-exec-shell-status';
+
+// Search-result parsing (web-search / fetch MCP tools)
+export {
+       SUPPORTED_WEB_SEARCH_TOOL_NAMES,
+       extractSearchResults,
+       extractSearchQuery,
+       faviconForUrl,
+       isWebSearchToolName,
+       type SearchResult
+} from './search-results';
+
 // Cache utilities
 export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
 
@@ -185,6 +220,19 @@ export {
        withAbortSignal
 } from './abort';
 
+// Tool-call meta utilities. Parsers for each built-in tool live next to
+// their renderer family under
+// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
+// This module only carries the helpers that genuinely cross tool
+// boundaries (currently: parsing the tool-result blob into a JSON
+// object).
+export { tryParseToolResultObject } from './tool-call-meta';
+
+// Per-tool UI metadata (label + icon) used by the tool-call chrome.
+// Re-exported through $lib/utils so renderer components can read the
+// label without depending on $lib/constants directly.
+export { getBuiltinToolUi, type BuiltinToolUiEntry } from '$lib/constants/built-in-tools';
+
 // Cryptography utilities
 
 export { uuid } from './uuid';
index c0a1e1c578fb631175b107abae79a03eff382f55..209f0f2ff5b16ef24a8f7c39d66c1034eaba18ce 100644 (file)
@@ -1,3 +1,5 @@
+import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
+
 /**
  * Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
  *
@@ -24,7 +26,7 @@ export function normalizeModelName(modelName: string): string {
                return '';
        }
 
-       const segments = trimmed.split(/[\\/]/);
+       const segments = trimmed.split(FILE_PATH_SEPARATOR_REGEX);
 
        // If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
        // and preserve the full "org/model" format
diff --git a/tools/ui/src/lib/utils/parse-exec-shell-error.ts b/tools/ui/src/lib/utils/parse-exec-shell-error.ts
new file mode 100644 (file)
index 0000000..86d47d2
--- /dev/null
@@ -0,0 +1,19 @@
+export function parseExecShellCommandError(
+       toolResultString: string | undefined
+): string | undefined {
+       if (!toolResultString) return undefined;
+       try {
+               const parsed: unknown = JSON.parse(toolResultString);
+               if (
+                       parsed &&
+                       typeof parsed === 'object' &&
+                       !Array.isArray(parsed) &&
+                       typeof (parsed as Record<string, unknown>).error === 'string'
+               ) {
+                       return (parsed as { error: string }).error;
+               }
+       } catch {
+               // Plain-text result = stdout/stderr, no structured error to surface.
+       }
+       return undefined;
+}
diff --git a/tools/ui/src/lib/utils/parse-exec-shell-status.ts b/tools/ui/src/lib/utils/parse-exec-shell-status.ts
new file mode 100644 (file)
index 0000000..b864e6b
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * Parsing helpers for `exec_shell_command` tool output.
+ *
+ * The server appends one final line to the response - an exit-code summary
+ * shaped as `[exit code: N]` (and optionally followed by `[exit due to timed
+ * out]`) - so the renderer can color that final line based on success/failure
+ * without parsing the entire output stream.
+ */
+
+export interface ExecShellExitStatus {
+       code: number;
+       timedOut: boolean;
+       /** Length-prefix slice for matching against the rendered lines list. */
+       rawText: string;
+}
+
+// Anchor to the absolute end so intermediate "[exit code: N]" string content
+// (e.g. a shell echo) doesn't false-positive.
+const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
+
+export function parseExecShellCommandExitStatus(
+       toolResultString: string | undefined
+): ExecShellExitStatus | undefined {
+       if (!toolResultString) return undefined;
+
+       const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
+       if (!match) return undefined;
+
+       return {
+               code: Number.parseInt(match[1], 10),
+               timedOut: match[0].includes('exit due to timed out'),
+               rawText: match[0]
+       };
+}
+
+/**
+ * Returns true when the supplied rendered line equals (trimmed) the
+ * trailing exit-code text. Used by the renderer to drop the duplicated
+ * representation (since the trailing line is replaced by a status badge).
+ */
+export function isExitCodeSummaryLine(
+       lineText: string,
+       status: ExecShellExitStatus | undefined
+): boolean {
+       if (!status) return false;
+       return lineText.trim() === status.rawText.trim();
+}
diff --git a/tools/ui/src/lib/utils/parse-partial-json-args.ts b/tools/ui/src/lib/utils/parse-partial-json-args.ts
new file mode 100644 (file)
index 0000000..09c1122
--- /dev/null
@@ -0,0 +1,83 @@
+// JSON delimiters used while scanning partial streamed JSON. Single-char
+// tokens so they only need eq-comparison, but naming them keeps the
+// scanner readable and keeps the literal source-of-truth in one place.
+const JSON_QUOTE = '"';
+const JSON_BACKSLASH = '\\';
+const JSON_OBJECT_OPEN = '{';
+const JSON_OBJECT_CLOSE = '}';
+const JSON_ARRAY_OPEN = '[';
+const JSON_ARRAY_CLOSE = ']';
+
+// Trailing punctuation to strip before re-closing a partial object/array.
+// Matches an optional trailing comma plus any trailing whitespace; lets
+// us re-emit a syntactically-valid JSON document without an orphaned
+// comma when the model cut off mid-key.
+const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/;
+
+// Parse partial tool-arg JSON streamed token-by-token. Closes any
+// unterminated string and dangling open containers (in reverse order),
+// so parsers can still surface keys already received while the call
+// is still in flight.
+export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null {
+       try {
+               const parsed: unknown = JSON.parse(toolArgsString);
+               if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                       return parsed as Record<string, unknown>;
+               }
+               return null;
+       } catch {
+               let inString = false;
+               let escape = false;
+               const stack: ('{' | '[')[] = [];
+
+               for (let i = 0; i < toolArgsString.length; i++) {
+                       const ch = toolArgsString[i];
+                       if (escape) {
+                               escape = false;
+                               continue;
+                       }
+                       if (ch === JSON_BACKSLASH && inString) {
+                               escape = true;
+                               continue;
+                       }
+                       if (ch === JSON_QUOTE) {
+                               inString = !inString;
+                               continue;
+                       }
+                       if (inString) continue;
+                       if (ch === JSON_OBJECT_OPEN) stack.push(JSON_OBJECT_OPEN);
+                       else if (ch === JSON_OBJECT_CLOSE) {
+                               if (stack.length === 0 || stack[stack.length - 1] !== JSON_OBJECT_OPEN) return null;
+                               stack.pop();
+                       } else if (ch === JSON_ARRAY_OPEN) stack.push(JSON_ARRAY_OPEN);
+                       else if (ch === JSON_ARRAY_CLOSE) {
+                               if (stack.length === 0 || stack[stack.length - 1] !== JSON_ARRAY_OPEN) return null;
+                               stack.pop();
+                       }
+               }
+
+               let completed = toolArgsString;
+               if (escape) {
+                       // Dangling escape at end of partial JSON: escape the trailing
+                       // backslash as a literal so we can close the string cleanly.
+                       completed += JSON_BACKSLASH;
+               }
+               if (inString) completed += JSON_QUOTE;
+               if (!inString) completed = completed.replace(TRAILING_JSON_PUNCTUATION_REGEX, '');
+
+               // Close in reverse nesting order: innermost container first.
+               for (let i = stack.length - 1; i >= 0; i--) {
+                       completed += stack[i] === JSON_OBJECT_OPEN ? JSON_OBJECT_CLOSE : JSON_ARRAY_CLOSE;
+               }
+
+               try {
+                       const parsed: unknown = JSON.parse(completed);
+                       if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                               return parsed as Record<string, unknown>;
+                       }
+                       return null;
+               } catch {
+                       return null;
+               }
+       }
+}
diff --git a/tools/ui/src/lib/utils/search-results.ts b/tools/ui/src/lib/utils/search-results.ts
new file mode 100644 (file)
index 0000000..861b0d2
--- /dev/null
@@ -0,0 +1,229 @@
+/**
+ * Parsers for MCP web-search tool responses shaped like:
+ *
+ *     Title: <text>
+ *     URL: <https url>
+ *     Published: <iso date or N/A>
+ *     Author: <name or N/A>
+ *     Highlights:
+ *     <multi-line excerpt>
+ *     ---
+ *     Title: <next result>
+ *     ...
+ *
+ * The model is content-driven (any tool emitting `Title:` / `URL:` lines
+ * separated by `---` qualifies), so it adapts to other web-search MCP
+ * servers without hardcoding tool names.
+ */
+
+export type SearchResult = {
+       title: string;
+       url: string;
+       published?: string;
+       author?: string;
+       highlights?: string;
+};
+
+const SEPARATOR_LINE_RE = /^\s*---\s*$/;
+const URL_SCHEME_RE = /^https?:\/\//i;
+
+// Match either Unix or Windows line endings so chunking/parsing handles
+// payloads written by either scheme without off-by-one mismatches.
+const LINE_BREAK_RE = /\r?\n/;
+
+// Sentinel the search-result wire format uses when a field is absent
+// (e.g. `Author: N/A`). Treated identically to a missing field so the
+// rendered card hides the row either way.
+const NOT_AVAILABLE_VALUE = 'N/A';
+
+// Section header that announces the start of the multi-line Highlights
+// block. Everything from that line onward (until the next `---`
+// separator or end of chunk) is captured verbatim as highlight text
+// instead of being re-scanned for `Title:`/`URL:`/... field lines.
+const HIGHLIGHTS_SECTION_HEADER = 'Highlights:';
+
+// Field name conventionally used by web-search tools (Exa etc.) as the
+// user-supplied query parameter. Extracted so future tool schemas that
+// adopt the same convention stay grep-compatible with this parser.
+const SEARCH_TOOL_QUERY_FIELD = 'query';
+
+// URL schemes the favicon helper will resolve to a hosted favicon. Any
+// other scheme (e.g. data:, blob:) intentionally returns null so the UI
+// can fall back to a generic globe icon.
+const RESOLVABLE_URL_PROTOCOLS: readonly string[] = ['https:', 'http:'];
+
+// Conventional favicon path served by virtually every web host.
+// Appended to the URL origin as a best-effort lookup target; ignore
+// 404s at render time.
+const FAVICON_PATH = '/favicon.ico';
+
+// Wire-format field names emitted by the search-result parser. String
+// values match the keys the chunk parser writes into the `fields` map
+// (and that callers read off `SearchResult`), so `FieldKey.TITLE` is a
+// drop-in for the literal `'title'`.
+enum FieldKey {
+       TITLE = 'title',
+       URL = 'url',
+       PUBLISHED = 'published',
+       AUTHOR = 'author'
+}
+const FIELD_PREFIXES: ReadonlyArray<{ key: FieldKey; prefix: string }> = [
+       { key: FieldKey.TITLE, prefix: 'Title:' },
+       { key: FieldKey.URL, prefix: 'URL:' },
+       { key: FieldKey.PUBLISHED, prefix: 'Published:' },
+       { key: FieldKey.AUTHOR, prefix: 'Author:' }
+];
+
+/**
+ * Split a tool result string into individual search-result chunks by
+ * scanning line-by-line for `---` separator rows. Handles multi-line
+ * safely (line-aware, not regex on the full string) so trailing /
+ * leading / consecutive separators are not lost.
+ */
+function splitChunks(text: string): string[] {
+       const lines = text.split(LINE_BREAK_RE);
+       const chunks: string[] = [];
+       let buffer: string[] = [];
+       for (const line of lines) {
+               if (SEPARATOR_LINE_RE.test(line)) {
+                       if (buffer.length > 0) {
+                               chunks.push(buffer.join('\n'));
+                               buffer = [];
+                       }
+               } else {
+                       buffer.push(line);
+               }
+       }
+       if (buffer.length > 0) chunks.push(buffer.join('\n'));
+       return chunks;
+}
+
+/**
+ * Parse a single chunk into a SearchResult. Returns null when the chunk
+ * has neither a title nor a URL â€” those are required for an entry to be
+ * actionable (otherwise it is almost certainly malformed or a stray
+ * separator line).
+ */
+function parseChunk(chunk: string): SearchResult | null {
+       const trimmed = chunk.trim();
+       if (!trimmed) return null;
+
+       const lines = chunk.split(LINE_BREAK_RE);
+
+       const fields: Record<FieldKey, string | undefined> = {
+               [FieldKey.TITLE]: undefined,
+               [FieldKey.URL]: undefined,
+               [FieldKey.PUBLISHED]: undefined,
+               [FieldKey.AUTHOR]: undefined
+       };
+       const highlightLines: string[] = [];
+       let inHighlights = false;
+
+       for (const line of lines) {
+               if (!inHighlights && line.trim() === HIGHLIGHTS_SECTION_HEADER) {
+                       inHighlights = true;
+                       continue;
+               }
+
+               if (inHighlights) {
+                       highlightLines.push(line);
+                       continue;
+               }
+
+               for (const { key, prefix } of FIELD_PREFIXES) {
+                       if (!line.startsWith(prefix)) continue;
+                       const value = line.slice(prefix.length).trim();
+                       if (value && value !== NOT_AVAILABLE_VALUE) {
+                               fields[key] = value;
+                       }
+                       break;
+               }
+       }
+
+       if (!fields[FieldKey.TITLE] || !fields[FieldKey.URL] || !URL_SCHEME_RE.test(fields[FieldKey.URL]))
+               return null;
+
+       const highlights = highlightLines.join('\n').trim();
+
+       const result: SearchResult = {
+               title: fields[FieldKey.TITLE],
+               url: fields[FieldKey.URL]
+       };
+       if (fields[FieldKey.PUBLISHED]) result.published = fields[FieldKey.PUBLISHED];
+       if (fields[FieldKey.AUTHOR]) result.author = fields[FieldKey.AUTHOR];
+       if (highlights) result.highlights = highlights;
+       return result;
+}
+
+/**
+ * Extract a SearchResult[] from a tool-result string. Returns `[]` when
+ * the input does not match the expected shape â€” useful for branching
+ * between dedicated search-results rendering and the generic tool-call
+ * block.
+ */
+export function extractSearchResults(text: string | undefined | null): SearchResult[] {
+       if (!text) return [];
+
+       const results: SearchResult[] = [];
+       for (const chunk of splitChunks(text)) {
+               const parsed = parseChunk(chunk);
+               if (parsed) results.push(parsed);
+       }
+       return results;
+}
+
+/**
+ * Best-effort extraction of the search query out of a tool call's JSON
+ * argument blob. Currently looks for a `query` field (the convention
+ * used by Exa and most web-search MCP servers); returns an empty string
+ * if it cannot be located.
+ */
+export function extractSearchQuery(toolArgs: string | undefined | null): string {
+       if (!toolArgs) return '';
+       try {
+               const parsed: unknown = JSON.parse(toolArgs);
+               if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                       const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
+                       if (typeof candidate === 'string') return candidate.trim();
+               }
+       } catch {
+               return '';
+       }
+       return '';
+}
+
+/**
+ * Resolve a best-effort favicon URL for a search result, derived from the
+ * result's origin (`https://host/favicon.ico`). Returns `null` when the
+ * URL is malformed, has no recognizable host, or uses a non-http(s)
+ * scheme â€” callers should fall back to a generic globe icon.
+ */
+export function faviconForUrl(url: string): string | null {
+       try {
+               const parsed = new URL(url);
+               if (!RESOLVABLE_URL_PROTOCOLS.includes(parsed.protocol)) return null;
+               return `${parsed.protocol}//${parsed.host}${FAVICON_PATH}`;
+       } catch {
+               return null;
+       }
+}
+
+// Web-search MCP servers broadly follow the `web_search` token convention
+// for their primary tool, but the rich pill UI makes assumptions about
+// both the request shape (single `query` string) and the response shape
+// (Title:/URL:/Published:/Author:/Highlights blocks). Adding a tool here
+// is a deliberate signal that the renderer is known to handle its output.
+// Continued maintenance note: when broadening this list, verify both the
+// tool schema and the response format against the supported spec above.
+export const SUPPORTED_WEB_SEARCH_TOOL_NAMES: readonly string[] = ['web_search_exa'];
+
+/**
+ * True when the tool's name is in the explicit allow-list of web-search
+ * tools above. Returned to the dispatcher so it can route the call's UI
+ * early (before results arrive) without false-firing on non-web-search
+ * tools that also happen to accept a `query` argument.
+ */
+export function isWebSearchToolName(toolName: string | undefined | null): boolean {
+       if (!toolName) return false;
+       return SUPPORTED_WEB_SEARCH_TOOL_NAMES.includes(toolName);
+}
diff --git a/tools/ui/src/lib/utils/sse.ts b/tools/ui/src/lib/utils/sse.ts
new file mode 100644 (file)
index 0000000..80c9090
--- /dev/null
@@ -0,0 +1,71 @@
+import {
+       SSE_DATA_PREFIX,
+       SSE_DONE_MARKER,
+       SSE_LINE_SEPARATOR,
+       SSE_RECORD_SEPARATOR
+} from '$lib/constants';
+
+/**
+ * Minimal SSE-with-JSON stream iterator.
+ *
+ * Yields one event per `\n\n`-separated record. Each event payload is the
+ * decoded `data:` field after JSON-parsing. A `[DONE]` sentinel terminates
+ * the stream early. Malformed records - any record whose `data:` payload
+ * fails `JSON.parse` - are skipped silently: usually a transient mid-stream
+ * fault that the caller should not have to special-case, and the noise of
+ * logging every occurrence on long-running streams outweighs the diagnostic
+ * value.
+ *
+ * Less ambitious than ChatService.handleStreamResponse (no resume, no byte
+ * offset tracking) - suitable for one-shot streams like `/tools?stream=true`
+ * where the consumer just reads chunks until done.
+ */
+
+export interface SseJsonEvent<T = unknown> {
+       data: T;
+}
+
+export async function* parseSseJsonStream<T = unknown>(
+       response: Response,
+       signal?: AbortSignal
+): AsyncGenerator<SseJsonEvent<T>> {
+       const reader = response.body?.getReader();
+       if (!reader) return;
+
+       const decoder = new TextDecoder();
+       let buffer = '';
+
+       try {
+               while (true) {
+                       if (signal?.aborted) return;
+
+                       const { done, value } = await reader.read();
+                       if (done) break;
+
+                       buffer += decoder.decode(value, { stream: true });
+                       const records = buffer.split(SSE_RECORD_SEPARATOR);
+                       buffer = records.pop() ?? '';
+
+                       for (const record of records) {
+                               if (!record) continue;
+                               for (const line of record.split(SSE_LINE_SEPARATOR)) {
+                                       if (!line.startsWith(SSE_DATA_PREFIX)) continue;
+                                       const payload = line.slice(SSE_DATA_PREFIX.length).trim();
+                                       if (payload === SSE_DONE_MARKER) return;
+                                       if (!payload) continue;
+                                       try {
+                                               yield { data: JSON.parse(payload) as T };
+                                       } catch {
+                                               // Skip silently per the function contract above.
+                                       }
+                               }
+                       }
+               }
+       } finally {
+               try {
+                       reader.releaseLock();
+               } catch (error) {
+                       console.error('[sse] failed to release reader lock:', error);
+               }
+       }
+}
index a2a4a1b57aefb4a8b9ea8ce0af2aae10f848a7e2..18a36eb8a26545050f47cec57a980177b767b854 100644 (file)
@@ -1,4 +1,4 @@
-import { NEWLINE_SEPARATOR } from '$lib/constants';
+import { NEWLINE } from '$lib/constants';
 
 /**
  * Returns a shortened preview of the provided content capped at the given length.
@@ -14,7 +14,7 @@ export function getPreviewText(content: string, max = 150): string {
  */
 export function generateConversationTitle(content: string, useFirstLine: boolean = false): string {
        if (useFirstLine) {
-               const firstLine = content.split(NEWLINE_SEPARATOR).find((line) => line.trim().length > 0);
+               const firstLine = content.split(NEWLINE).find((line) => line.trim().length > 0);
                return firstLine ? firstLine.trim() : content.trim();
        }
 
diff --git a/tools/ui/src/lib/utils/tool-call-meta.ts b/tools/ui/src/lib/utils/tool-call-meta.ts
new file mode 100644 (file)
index 0000000..798ba7b
--- /dev/null
@@ -0,0 +1,27 @@
+// Generic helper for parsing tool-result blobs (the "out" side of a
+// tool call). Used by the per-tool meta parsers under
+// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
+// Each tool needs to surface fields like `error`, `result`, `bytes`,
+// `edits_applied` without repeating the try/JSON.parse/object guard inline.
+
+/**
+ * Parse a tool-result blob into a JSON object, or `null` if it isn't
+ * one. Returns null for:
+ *   - missing / empty input,
+ *   - a JSON object that turns out to be an array or primitive,
+ *   - any parse failure (always returns null rather than throwing).
+ */
+export function tryParseToolResultObject(
+       toolResultString: string | undefined
+): Record<string, unknown> | null {
+       if (!toolResultString) return null;
+       try {
+               const parsed: unknown = JSON.parse(toolResultString);
+               if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                       return parsed as Record<string, unknown>;
+               }
+               return null;
+       } catch {
+               return null;
+       }
+}
index e8b78f7bd404ccef88174a40b4c4f078f6b7acd1..f1bf9ecb8bbd601689a6f51e707d1808f30b7383 100644 (file)
@@ -1,4 +1,8 @@
-import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants';
+import {
+       TRAILING_SLASHES_REGEX,
+       TWO_PART_PUBLIC_SUFFIXES,
+       WILDCARD_PUBLIC_SUFFIXES
+} from '$lib/constants';
 import { UrlProtocol } from '$lib/enums';
 
 /**
@@ -70,3 +74,39 @@ export function sanitizeExternalUrl(raw: string): string | null {
                return null;
        }
 }
+
+/**
+ * Canonicalize a server URL for "is this the same server?" checks across
+ * the user's settings and the recommended-server list. Lowercases scheme
+ * and host, drops the port entirely, and strips any trailing slashes off
+ * the path so a stored `https://api.example.com:8443/mcp/` matches the
+ * recommended `https://api.example.com/mcp`. Falls back to a cheap
+ * trim+lowercase+strip pass when the input isn't a parseable URL.
+ *
+ * Query strings are preserved deliberately - if the user entered one,
+ * it's part of their endpoint. The port is always stripped because the
+ * underlying `URL` parser is asymmetric (it auto-drops HTTPS default
+ * :443 but keeps HTTP default :80), so a half-hearted "drop default
+ * ports" policy never matches consistently across schemes.
+ */
+export function canonicalizeServerUrl(raw: string): string {
+       const trimmed = raw.trim();
+
+       try {
+               const parsed = new URL(trimmed);
+               const pathname = parsed.pathname.replace(TRAILING_SLASHES_REGEX, '');
+
+               // Aggressive: drop the port unconditionally. We only use this for
+               // equality checks between user-typed URLs and a hard-coded list of
+               // recommendations, where the port can never carry distinguishing
+               // information we care about (a different port = a different server,
+               // but two URLs that differ only in `:80` vs no-port are clearly the
+               // same intent). Lowercasing the hostname matches HTTP/HTTPS
+               // case-insensitivity - the URL parser does NOT lowercase it.
+               const host = parsed.hostname.toLowerCase();
+
+               return `${parsed.protocol}//${host}${pathname}${parsed.search}`;
+       } catch {
+               return trimmed.toLowerCase().replace(TRAILING_SLASHES_REGEX, '');
+       }
+}
index f3c024bb9a7e1ad6ca6c26fd7e342e0e6a08ef63..ba1c78fbb2ec28e1bd4d4585e4fee63e8b9b92b1 100644 (file)
@@ -22,7 +22,7 @@
        import { Toaster } from 'svelte-sonner';
        import { modelsStore } from '$lib/stores/models.svelte';
        import { mcpStore } from '$lib/stores/mcp.svelte';
-       import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
+       import { AUTHORIZATION_HEADER, BEARER_PREFIX, TOOLTIP_DELAY_DURATION } from '$lib/constants';
        import { FAVICON_PATHS, FAVICON_SELECTORS } from '$lib/constants/pwa';
        import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
        import { usePwa } from '$lib/hooks/use-pwa.svelte';
                        ) {
                                const headers: Record<string, string> = {
                                        'Content-Type': 'application/json',
-                                       Authorization: `Bearer ${apiKey.trim()}`
+                                       [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey.trim()}`
                                };
 
                                fetch(`${base}/props`, { headers })
                };
        });
 
-       // Background MCP server health checks on app load
-       // Fetch enabled servers from settings and run health checks in background.
+       // Background MCP server health checks on app load.
+       // Health-check every configured server with a URL - including disabled ones -
+       // so the /mcp-servers page can display health metadata for servers that are
+       // currently turned off. Disabled servers never get promoted to active
+       // connections (see runHealthCheck), so their tools/prompts/resources stay
+       // out of the chat-side stores.
        // Only IDLE servers are checked; already-resolved (SUCCESS / ERROR) servers
        // keep their existing state, so adding or removing a server does not flash
        // every other card back through skeleton state.
 
                const mcpServers = mcpStore.getServers();
 
-               // Only run health checks if we have enabled servers with URLs
-               const enabledServers = mcpServers.filter((s) => s.enabled && s.url.trim());
+               const serversWithUrls = mcpServers.filter((s) => s.url.trim());
 
-               if (enabledServers.length > 0) {
+               if (serversWithUrls.length > 0) {
                        untrack(() => {
                                // Run health checks in background (don't await)
-                               mcpStore.runHealthChecksForServers(enabledServers, true).catch((error) => {
+                               mcpStore.runHealthChecksForServers(serversWithUrls, true).catch((error) => {
                                        console.warn('[layout] MCP health checks failed:', error);
                                });
                        });
diff --git a/tools/ui/static/recommended-mcp/context7.png b/tools/ui/static/recommended-mcp/context7.png
new file mode 100644 (file)
index 0000000..baa8d7b
Binary files /dev/null and b/tools/ui/static/recommended-mcp/context7.png differ
diff --git a/tools/ui/static/recommended-mcp/exa.ico b/tools/ui/static/recommended-mcp/exa.ico
new file mode 100644 (file)
index 0000000..25e4283
Binary files /dev/null and b/tools/ui/static/recommended-mcp/exa.ico differ
diff --git a/tools/ui/static/recommended-mcp/github-dark.png b/tools/ui/static/recommended-mcp/github-dark.png
new file mode 100644 (file)
index 0000000..0dafe28
Binary files /dev/null and b/tools/ui/static/recommended-mcp/github-dark.png differ
diff --git a/tools/ui/static/recommended-mcp/github-light.png b/tools/ui/static/recommended-mcp/github-light.png
new file mode 100644 (file)
index 0000000..fd55374
Binary files /dev/null and b/tools/ui/static/recommended-mcp/github-light.png differ
diff --git a/tools/ui/static/recommended-mcp/huggingface.ico b/tools/ui/static/recommended-mcp/huggingface.ico
new file mode 100644 (file)
index 0000000..0a3ea61
Binary files /dev/null and b/tools/ui/static/recommended-mcp/huggingface.ico differ
index 7af882143d4e5a4a199e9e35aa0377eed06e1e31..0efe5968ce83f1dce01ac42f12f4ab4c478a47ad 100644 (file)
@@ -95,6 +95,29 @@ describe('deriveAgenticSections', () => {
                expect(sections[0].toolName).toBe('bash');
        });
 
+       it('chat-streaming write_file surfaces as TOOL_CALL_PENDING with partial toolArgs (not TOOL_CALL_STREAMING)', () => {
+               // Regression: while the LLM is emitting a write_file tool call's
+               // args, `chat.svelte.ts` JSON-encodes the partial tool-call array on
+               // every chunk, so `parseToolCalls` succeeds and the section is
+               // classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is
+               // only produced from the `streamingToolCalls` parameter, never set
+               // by current UI callers). Streaming-only UI like auto-scroll in the
+               // code block must still trigger, driven by `isStreaming && (isPending
+               // || isStreamingCall)`, not `isStreamingCall` alone.
+               const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
+               const msg = makeAssistant({
+                       toolCalls: JSON.stringify([
+                               { id: 'call_1', type: 'function', function: { name: 'write_file', arguments: partialArgs } }
+                       ])
+               });
+               const sections = deriveAgenticSections(msg, [], [], true);
+               expect(sections).toHaveLength(1);
+               expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
+               expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
+               expect(sections[0].toolName).toBe('write_file');
+               expect(sections[0].toolArgs).toBe(partialArgs);
+       });
+
        it('multi-turn: two assistant turns grouped as one session', () => {
                const assistant1 = makeAssistant({
                        id: 'ast-1',
diff --git a/tools/ui/tests/unit/assistant-raw-output.test.ts b/tools/ui/tests/unit/assistant-raw-output.test.ts
new file mode 100644 (file)
index 0000000..48a75be
--- /dev/null
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { AgenticSectionType } from '$lib/enums';
+import { REASONING_TAGS } from '$lib/constants';
+import { buildAssistantRawOutput, type AgenticSection } from '$lib/utils/agentic';
+
+function makeSection(
+       overrides: Partial<AgenticSection> & { type: AgenticSectionType }
+): AgenticSection {
+       return {
+               content: '',
+               ...overrides
+       };
+}
+
+describe('buildAssistantRawOutput', () => {
+       it('returns empty string for empty sections', () => {
+               expect(buildAssistantRawOutput([])).toBe('');
+       });
+
+       it('formats a reasoning section with a single newline between tags and content', () => {
+               const sections = [makeSection({ type: AgenticSectionType.REASONING, content: 'thinking...' })];
+               expect(buildAssistantRawOutput(sections)).toBe(
+                       `${REASONING_TAGS.START}\nthinking...${REASONING_TAGS.END}`
+               );
+       });
+
+       it('formats a text section as-is', () => {
+               const sections = [makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' })];
+               expect(buildAssistantRawOutput(sections)).toBe('Hello');
+       });
+
+       it('formats a tool call with JSON args and no result label', () => {
+               const sections = [
+                       makeSection({
+                               type: AgenticSectionType.TOOL_CALL,
+                               toolName: 'read_file',
+                               toolArgs: JSON.stringify({ path: '/tmp/file.txt' }),
+                               toolResult: 'file contents'
+                       })
+               ];
+               expect(buildAssistantRawOutput(sections)).toBe(
+                       [
+                               '{',
+                               '  "name": "read_file",',
+                               '  "arguments": {',
+                               '    "path": "/tmp/file.txt"',
+                               '  }',
+                               '}',
+                               '',
+                               '',
+                               'file contents'
+                       ].join('\n')
+               );
+       });
+
+       it('joins multiple sections with double newlines', () => {
+               const sections = [
+                       makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' }),
+                       makeSection({ type: AgenticSectionType.TOOL_CALL, toolName: 'noop' })
+               ];
+               expect(buildAssistantRawOutput(sections)).toBe('Hello\n\n{\n  "name": "noop"\n}');
+       });
+
+       it('falls back to raw string args when JSON parsing fails', () => {
+               const sections = [
+                       makeSection({
+                               type: AgenticSectionType.TOOL_CALL,
+                               toolName: 'broken',
+                               toolArgs: '{not json',
+                               toolResult: 'result'
+                       })
+               ];
+               expect(buildAssistantRawOutput(sections)).toBe(
+                       ['{', '  "name": "broken",', '  "arguments": "{not json"', '}', '', '', 'result'].join('\n')
+               );
+       });
+});
diff --git a/tools/ui/tests/unit/classify-tool-result.test.ts b/tools/ui/tests/unit/classify-tool-result.test.ts
new file mode 100644 (file)
index 0000000..a1f2c8d
--- /dev/null
@@ -0,0 +1,130 @@
+import { describe, it, expect } from 'vitest';
+import { classifyToolResult } from '$lib/utils/agentic';
+
+describe('classifyToolResult', () => {
+       describe('text', () => {
+               it('returns text for undefined input', () => {
+                       expect(classifyToolResult(undefined)).toBe('text');
+               });
+
+               it('returns text for empty string', () => {
+                       expect(classifyToolResult('')).toBe('text');
+               });
+
+               it('returns text for whitespace-only input', () => {
+                       expect(classifyToolResult('   \n  ')).toBe('text');
+               });
+
+               it('returns text for plain prose', () => {
+                       expect(classifyToolResult('Hello, this is just some text.')).toBe('text');
+               });
+
+               it('returns text for shell-style line listings', () => {
+                       expect(classifyToolResult('file1.java\nfile2.java\nfile3.java\n')).toBe('text');
+               });
+
+               it('returns text when a brace-like string is not valid JSON', () => {
+                       expect(classifyToolResult('{key: value}')).toBe('text');
+               });
+       });
+
+       describe('json', () => {
+               it('classifies a flat JSON object', () => {
+                       expect(classifyToolResult('{"key": "value", "n": 42}')).toBe('json');
+               });
+
+               it('classifies a JSON array', () => {
+                       expect(classifyToolResult('["a", "b", "c"]')).toBe('json');
+               });
+
+               it('classifies a pretty-printed JSON object', () => {
+                       expect(classifyToolResult('{\n  "key": "value"\n}')).toBe('json');
+               });
+
+               it('classifies a deeply nested JSON payload', () => {
+                       const nested = JSON.stringify({ items: [{ id: 1, tags: ['a', 'b'] }] }, null, 2);
+                       expect(classifyToolResult(nested)).toBe('json');
+               });
+
+               it('prefers JSON over inner markdown markers when the content starts with a brace', () => {
+                       // A JSON object whose inner strings contain link syntax still
+                       // reads as JSON because the leading `{` parses cleanly -
+                       // `classifyToolResult` only inspects the top-level shape, not
+                       // every nested line marker.
+                       const jsonWithLink = '{"docs": "see [docs](https://example.com) for more"}';
+                       expect(classifyToolResult(jsonWithLink)).toBe('json');
+               });
+       });
+
+       describe('markdown', () => {
+               it('classifies an ATX header line', () => {
+                       expect(classifyToolResult('# Title\n\nSome text below.')).toBe('markdown');
+               });
+
+               it('classifies a fenced code block', () => {
+                       expect(classifyToolResult('```json\n{"key": "v"}\n```')).toBe('markdown');
+               });
+
+               it('classifies a tilde-fenced code block', () => {
+                       expect(classifyToolResult('~~~bash\nls -la\n~~~')).toBe('markdown');
+               });
+
+               it('classifies a markdown link', () => {
+                       expect(classifyToolResult('See [docs](https://example.com) for more.')).toBe('markdown');
+               });
+
+               it('classifies bold text', () => {
+                       expect(classifyToolResult('This is **very important**.')).toBe('markdown');
+               });
+
+               it('classifies a bulleted list', () => {
+                       expect(classifyToolResult('- item one\n- item two\n- item three')).toBe('markdown');
+               });
+
+               it('classifies an ordered list', () => {
+                       expect(classifyToolResult('1. first step\n2. second step\n3. third step')).toBe('markdown');
+               });
+
+               it('classifies a blockquote', () => {
+                       expect(classifyToolResult('> quoted text\n> second line')).toBe('markdown');
+               });
+
+               it('classifies a markdown table', () => {
+                       const table = '| a | b |\n| - | - |\n| 1 | 2 |';
+                       expect(classifyToolResult(table)).toBe('markdown');
+               });
+
+               it('classifies a markdown table with alignment markers', () => {
+                       const table = '| left | center | right |\n| :--- | :---: | ---: |\n| a | b | c |';
+                       expect(classifyToolResult(table)).toBe('markdown');
+               });
+
+               it('classifies nested markdown headings', () => {
+                       expect(classifyToolResult('## Section\n\n### Subsection\n')).toBe('markdown');
+               });
+
+               it('classifies combined markdown markers in one document', () => {
+                       const md = [
+                               '# Heading',
+                               '',
+                               'A paragraph with a [link](https://example.com) and **bold text**.',
+                               '',
+                               '- bullet item',
+                               '- another bullet',
+                               '',
+                               '| col1 | col2 |',
+                               '| ----- | ----- |',
+                               '| a     | b     |'
+                       ].join('\n');
+                       expect(classifyToolResult(md)).toBe('markdown');
+               });
+       });
+
+       describe('precedence', () => {
+               it('prefers JSON over markdown when both signals are present', () => {
+                       // Starts with `[`, parses as JSON - markdown check is skipped.
+                       const arr = '[1, 2, "# not-a-heading", "**not-bold**"]';
+                       expect(classifyToolResult(arr)).toBe('json');
+               });
+       });
+});
diff --git a/tools/ui/tests/unit/code.test.ts b/tools/ui/tests/unit/code.test.ts
new file mode 100644 (file)
index 0000000..944cf8b
--- /dev/null
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest';
+import { highlightCode, trimCodePadding } from '$lib/utils/code';
+
+describe('trimCodePadding', () => {
+       it('removes a single leading newline', () => {
+               expect(trimCodePadding('\nfunction foo() {}')).toBe('function foo() {}');
+       });
+
+       it('removes multiple leading newlines', () => {
+               expect(trimCodePadding('\n\n\nfunction foo() {}')).toBe('function foo() {}');
+       });
+
+       it('removes whitespace-only leading lines', () => {
+               expect(trimCodePadding('\n  \n\t\nfunction foo() {}')).toBe('function foo() {}');
+       });
+
+       it('removes a single trailing newline', () => {
+               expect(trimCodePadding('function foo() {}\n')).toBe('function foo() {}');
+       });
+
+       it('removes multiple trailing newlines', () => {
+               expect(trimCodePadding('function foo() {}\n\n\n')).toBe('function foo() {}');
+       });
+
+       it('removes whitespace-only trailing lines', () => {
+               expect(trimCodePadding('function foo() {}\n  \n\t\n')).toBe('function foo() {}');
+       });
+
+       it('removes newlines on both sides at once', () => {
+               expect(trimCodePadding('\nfunction foo() {}\n')).toBe('function foo() {}');
+       });
+
+       it('preserves internal blank lines', () => {
+               expect(trimCodePadding('\nfunction foo() {\n\n  return 1;\n}\n')).toBe(
+                       'function foo() {\n\n  return 1;\n}'
+               );
+       });
+
+       it('drops a leading whitespace-only line but keeps following code intact', () => {
+               expect(trimCodePadding('  \nfunction foo() {}')).toBe('function foo() {}');
+       });
+
+       it('passes through already-trimmed input unchanged', () => {
+               expect(trimCodePadding('function foo() {}')).toBe('function foo() {}');
+               expect(trimCodePadding('function foo() {\n  return 1;\n}')).toBe(
+                       'function foo() {\n  return 1;\n}'
+               );
+       });
+
+       it('returns empty string when input is whitespace only', () => {
+               expect(trimCodePadding('\n\n\n')).toBe('');
+               expect(trimCodePadding('\n  \n\t\n')).toBe('');
+       });
+});
+
+describe('highlightCode', () => {
+       it('returns empty string for empty input', () => {
+               expect(highlightCode('', 'javascript')).toBe('');
+       });
+
+       it('does not produce a leading newline in the highlighted html', () => {
+               const html = highlightCode('\nfunction multiply(a, b) {\n  return a * b;\n}\n', 'javascript');
+               expect(html.startsWith('\n')).toBe(false);
+               expect(html.startsWith(' ')).toBe(false);
+       });
+
+       it('does not produce a trailing newline in the highlighted html', () => {
+               const html = highlightCode('\nfunction foo() {}\n', 'javascript');
+               expect(html.endsWith('\n')).toBe(false);
+       });
+
+       it('preserves internal blank lines in highlighted code', () => {
+               const html = highlightCode('\nfunction foo() {\n\n  return 1;\n}\n', 'javascript');
+               expect(html).toContain('\n\n');
+       });
+
+       it('produces the same body for framed and unframed input', () => {
+               const trimmed = highlightCode('function foo() {}', 'javascript');
+               const framed = highlightCode('\nfunction foo() {}\n', 'javascript');
+               expect(framed).toBe(trimmed);
+       });
+});
diff --git a/tools/ui/tests/unit/compute-line-diff.test.ts b/tools/ui/tests/unit/compute-line-diff.test.ts
new file mode 100644 (file)
index 0000000..4610603
--- /dev/null
@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest';
+import { DiffLineKind } from '$lib/enums';
+import { computeLineDiff, renderUnifiedDiff, type DiffLine } from '$lib/utils';
+
+describe('computeLineDiff', () => {
+       it('returns empty for two empty inputs', () => {
+               expect(computeLineDiff('', '')).toEqual([]);
+       });
+
+       it('marks every line as removed for an empty new text', () => {
+               expect(computeLineDiff('a\nb\nc', '')).toEqual([
+                       { kind: 'remove', text: 'a', oldLine: 1 },
+                       { kind: 'remove', text: 'b', oldLine: 2 },
+                       { kind: 'remove', text: 'c', oldLine: 3 }
+               ]);
+       });
+
+       it('marks every line as added for an empty old text', () => {
+               expect(computeLineDiff('', 'a\nb')).toEqual([
+                       { kind: 'add', text: 'a', newLine: 1 },
+                       { kind: 'add', text: 'b', newLine: 2 }
+               ]);
+       });
+
+       it('detects a single-line replace', () => {
+               expect(computeLineDiff('old', 'new')).toEqual([
+                       { kind: 'add', text: 'new', newLine: 1 },
+                       { kind: 'remove', text: 'old', oldLine: 1 }
+               ]);
+       });
+
+       it('preserves interleaved context around additions', () => {
+               const oldText = ['a', 'b', 'c'].join('\n');
+               const newText = ['a', 'b', 'B', 'c'].join('\n');
+               expect(computeLineDiff(oldText, newText)).toEqual([
+                       { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
+                       { kind: 'add', text: 'B', newLine: 3 },
+                       { kind: 'context', text: 'c', oldLine: 3, newLine: 4 }
+               ]);
+       });
+
+       it('preserves interleaved context around an isolated replace', () => {
+               // Multi-line context around a one-line change -> the diff should
+               // show context flanking the changed line at its natural position.
+               const oldText = ['a', 'b', 'c', 'd'].join('\n');
+               const newText = ['a', 'b', 'X', 'd'].join('\n');
+               expect(computeLineDiff(oldText, newText)).toEqual([
+                       { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
+                       { kind: 'add', text: 'X', newLine: 3 },
+                       { kind: 'remove', text: 'c', oldLine: 3 },
+                       { kind: 'context', text: 'd', oldLine: 4, newLine: 4 }
+               ]);
+       });
+
+       it('preserves interleaved context around removals', () => {
+               const oldText = ['a', 'b', 'c', 'd'].join('\n');
+               const newText = ['a', 'c', 'd'].join('\n');
+               expect(computeLineDiff(oldText, newText)).toEqual([
+                       { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: 'remove', text: 'b', oldLine: 2 },
+                       { kind: 'context', text: 'c', oldLine: 3, newLine: 2 },
+                       { kind: 'context', text: 'd', oldLine: 4, newLine: 3 }
+               ]);
+       });
+
+       it('handles purely identical inputs', () => {
+               const text = 'x\ny\nz';
+               const result = computeLineDiff(text, text);
+               expect(result).toEqual([
+                       { kind: 'context', text: 'x', oldLine: 1, newLine: 1 },
+                       { kind: 'context', text: 'y', oldLine: 2, newLine: 2 },
+                       { kind: 'context', text: 'z', oldLine: 3, newLine: 3 }
+               ]);
+       });
+
+       it('strips a trailing newline on the old/new inputs', () => {
+               expect(computeLineDiff('a\n', 'a\nb\n')).toEqual([
+                       { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: 'add', text: 'b', newLine: 2 }
+               ]);
+       });
+
+       it('normalizes trailing CR on each line', () => {
+               expect(computeLineDiff('a\r\nb\r\n', 'a\nb')).toEqual([
+                       { kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: 'context', text: 'b', oldLine: 2, newLine: 2 }
+               ]);
+       });
+
+       it('keeps line numbers monotonic across mixed add/remove/context', () => {
+               const oldText = ['l1', 'l2', 'l3', 'l4', 'l5'].join('\n');
+               const newText = ['l1', 'l2-EDIT', 'l3', 'l4-NEW', 'l5'].join('\n');
+               const diff = computeLineDiff(oldText, newText);
+
+               // Walk the diff: every oldLine must increase strictly, and every
+               // newLine must increase strictly. Lines missing one side (add or
+               // remove) carry no number on that side.
+               let lastOld = 0;
+               let lastNew = 0;
+               for (const line of diff) {
+                       if (line.oldLine !== undefined) {
+                               expect(line.oldLine).toBeGreaterThan(lastOld);
+                               lastOld = line.oldLine;
+                       }
+                       if (line.newLine !== undefined) {
+                               expect(line.newLine).toBeGreaterThan(lastNew);
+                               lastNew = line.newLine;
+                       }
+               }
+       });
+});
+
+describe('renderUnifiedDiff', () => {
+       it('returns empty string for empty diff', () => {
+               expect(renderUnifiedDiff([])).toBe('');
+       });
+
+       it('prefixes each line with `+`, `-`, or a single space', () => {
+               const lines: DiffLine[] = [
+                       { kind: DiffLineKind.CONTEXT, text: 'ctx' },
+                       { kind: DiffLineKind.ADD, text: 'plus' },
+                       { kind: DiffLineKind.REMOVE, text: 'minus' }
+               ];
+               expect(renderUnifiedDiff(lines)).toBe(' ctx\n+plus\n-minus');
+       });
+
+       it('ignores oldLine/newLine metadata when emitting prefixes', () => {
+               const lines: DiffLine[] = [
+                       { kind: DiffLineKind.CONTEXT, text: 'a', oldLine: 1, newLine: 1 },
+                       { kind: DiffLineKind.ADD, text: 'b', newLine: 2 }
+               ];
+               expect(renderUnifiedDiff(lines)).toBe(' a\n+b');
+       });
+});
diff --git a/tools/ui/tests/unit/mcp-override-fallback.test.ts b/tools/ui/tests/unit/mcp-override-fallback.test.ts
new file mode 100644 (file)
index 0000000..344ef84
--- /dev/null
@@ -0,0 +1,143 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
+import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
+import type { DatabaseConversation } from '$lib/types/database';
+
+// node env unit project has no DOM, install a minimal localStorage backed by a Map
+beforeAll(() => {
+       const store = new Map<string, string>();
+       const polyfill: Storage = {
+               get length() {
+                       return store.size;
+               },
+               clear: () => store.clear(),
+               getItem: (k) => (store.has(k) ? store.get(k)! : null),
+               key: (i) => Array.from(store.keys())[i] ?? null,
+               removeItem: (k) => {
+                       store.delete(k);
+               },
+               setItem: (k, v) => {
+                       store.set(k, String(v));
+               }
+       };
+       (globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
+});
+
+/**
+ * Regression coverage for the bug where MCP servers flipped to "disabled"
+ * after sending the first message on a fresh chat (see comment in
+ * `MCPStore.createConversation`: empty `mcpServerOverrides` should inherit
+ * `mcpServers[i].enabled`, not be treated as all-off).
+ */
+describe('conversationsStore MCP override resolution', () => {
+       beforeEach(async () => {
+               localStorage.clear();
+               // Two configured servers: alpha is globally disabled, bravo enabled.
+               localStorage.setItem(
+                       CONFIG_LOCALSTORAGE_KEY,
+                       JSON.stringify({
+                               [SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
+                                       { id: 'alpha', enabled: false, url: 'https://alpha.example.com/mcp' },
+                                       { id: 'bravo', enabled: true, url: 'https://bravo.example.com/mcp' }
+                               ])
+                       })
+               );
+
+               // The settings store constructor bails in node env (no `browser`),
+               // so seed the config directly. The shape mirrors what `loadConfig`
+               // would build from localStorage.
+               const { settingsStore } = await import('$lib/stores/settings.svelte');
+               const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
+               const saved = JSON.parse(raw) as Record<string, unknown>;
+               settingsStore.config = {
+                       ...settingsStore.config,
+                       [SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
+               };
+       });
+
+       afterEach(() => {
+               localStorage.clear();
+       });
+
+       function makeConversation(
+               overrides?: { serverId: string; enabled: boolean }[]
+       ): DatabaseConversation {
+               return {
+                       id: 'conv-1',
+                       currNode: null,
+                       lastModified: 0,
+                       name: 'Test chat',
+                       mcpServerOverrides: overrides
+               };
+       }
+
+       it('inherits server.enabled when no conversation is active', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = null;
+
+               expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
+               expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
+       });
+
+       it('inherits server.enabled on a newly created chat with no overrides', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = makeConversation();
+
+               // Empty override list: must fall back to global server.enabled, not all-off.
+               expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
+               expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
+       });
+
+       it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = makeConversation(undefined);
+
+               expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
+               expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(true);
+       });
+
+       it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               // Override flips bravo off for this chat, alpha keeps its global default.
+               conversationsStore.activeConversation = makeConversation([
+                       { serverId: 'bravo', enabled: false }
+               ]);
+
+               expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
+               expect(conversationsStore.isMcpServerEnabledForChat('bravo')).toBe(false);
+       });
+
+       it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = makeConversation([
+                       { serverId: 'alpha', enabled: true }
+               ]);
+
+               expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
+                       { serverId: 'alpha', enabled: true },
+                       { serverId: 'bravo', enabled: true }
+               ]);
+       });
+
+       it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = makeConversation();
+
+               expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
+                       { serverId: 'alpha', enabled: false },
+                       { serverId: 'bravo', enabled: true }
+               ]);
+       });
+
+       it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
+               const { conversationsStore } = await import('$lib/stores/conversations.svelte');
+               conversationsStore.activeConversation = makeConversation([
+                       { serverId: 'alpha', enabled: true }
+               ]);
+
+               expect(conversationsStore.getMcpServerOverride('bravo')).toEqual({
+                       serverId: 'bravo',
+                       enabled: true
+               });
+       });
+});
diff --git a/tools/ui/tests/unit/parse-exec-shell-status.test.ts b/tools/ui/tests/unit/parse-exec-shell-status.test.ts
new file mode 100644 (file)
index 0000000..f4143d9
--- /dev/null
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { isExitCodeSummaryLine, parseExecShellCommandExitStatus } from '$lib/utils';
+
+describe('parseExecShellCommandExitStatus', () => {
+       it('returns undefined when result is empty', () => {
+               expect(parseExecShellCommandExitStatus(undefined)).toBeUndefined();
+               expect(parseExecShellCommandExitStatus('')).toBeUndefined();
+       });
+
+       it('parses a zero-exit summary at end of clean stdout', () => {
+               const status = parseExecShellCommandExitStatus('hello world\n[exit code: 0]');
+               expect(status).toEqual({
+                       code: 0,
+                       timedOut: false,
+                       rawText: '[exit code: 0]'
+               });
+       });
+
+       it('parses a non-zero exit summary', () => {
+               const status = parseExecShellCommandExitStatus('cargo: error[E0425]\n[exit code: 101]');
+               expect(status?.code).toBe(101);
+               expect(status?.timedOut).toBe(false);
+       });
+
+       it('detects timed-out suffix', () => {
+               const status = parseExecShellCommandExitStatus(
+                       'still building...\n[exit code: -1] [exit due to timed out]'
+               );
+               expect(status?.code).toBe(-1);
+               expect(status?.timedOut).toBe(true);
+       });
+
+       it('tolerates trailing whitespace after the tail line', () => {
+               const status = parseExecShellCommandExitStatus('[exit code: 0]   \n\n');
+               expect(status?.code).toBe(0);
+       });
+
+       it('does not match an explanatory mention of "[exit code:" not at end', () => {
+               // Any non-trailing occurrence should NOT trigger the badge - we
+               // anchor to the absolute end of the string.
+               const status = parseExecShellCommandExitStatus(
+                       'the shell prints [exit code: 0]\nwhen done\nreally done\n'
+               );
+               expect(status).toBeUndefined();
+       });
+
+       it('does not match mid-stream exit lines followed by more output', () => {
+               const status = parseExecShellCommandExitStatus('[exit code: 0]\nmore output keeps streaming');
+               expect(status).toBeUndefined();
+       });
+});
+
+describe('isExitCodeSummaryLine', () => {
+       const status = parseExecShellCommandExitStatus('hello\n[exit code: 7]');
+
+       it('matches when line trims to the tail text', () => {
+               expect(isExitCodeSummaryLine('   [exit code: 7]   ', status)).toBe(true);
+       });
+
+       it('does not match unrelated lines', () => {
+               expect(isExitCodeSummaryLine('plain output line', status)).toBe(false);
+       });
+
+       it('returns false for missing status argument', () => {
+               expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/unit/partial-tool-call-cleanup.test.ts b/tools/ui/tests/unit/partial-tool-call-cleanup.test.ts
new file mode 100644 (file)
index 0000000..efceb45
--- /dev/null
@@ -0,0 +1,95 @@
+import { describe, it, expect } from 'vitest';
+import { MessageRole } from '$lib/enums';
+import { deriveAgenticSections } from '$lib/utils/agentic';
+import type { DatabaseMessage } from '$lib/types/database';
+
+function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
+       return {
+               id: overrides.id ?? 'ast-1',
+               convId: 'conv-1',
+               type: 'text',
+               timestamp: Date.now(),
+               role: MessageRole.ASSISTANT,
+               content: overrides.content ?? '',
+               parent: null,
+               children: [],
+               ...overrides
+       } as DatabaseMessage;
+}
+
+// Mirrors the filter inside ChatService.convertDbMessageToApiChatMessageData:
+// a partial tool call captured mid-stream must not survive into the next request
+// payload. The fix in chatStore.savePartialResponseIfNeeded clears toolCalls to ''
+// on Stop/Send immediately, mirroring what the agentic flow already does in
+// onAssistantTurnComplete(...undefined).
+function buildApiToolCalls(message: DatabaseMessage): unknown[] | undefined {
+       if (!message.toolCalls) return undefined;
+       try {
+               const parsed = JSON.parse(message.toolCalls);
+               return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
+       } catch {
+               return undefined;
+       }
+}
+
+describe('partial tool call cleanup', () => {
+       // Reproduces the broken payload from the user's screenshot: model was
+       // streaming a tool call whose arguments JSON was cut mid-string. The outer
+       // envelope still parses, but the arguments themselves are invalid JSON and
+       // the server rejects the request.
+       it('marks a partial tool call payload as unsafe to re-send', () => {
+               const message = makeAssistant({
+                       content: 'partial reasoning',
+                       toolCalls: JSON.stringify([
+                               {
+                                       id: 'call_1',
+                                       type: 'function',
+                                       function: {
+                                               name: 'exec_shell_command',
+                                               arguments: '{"command":`grep -n \\"read_to\\" ` /Users'
+                                       }
+                               }
+                       ])
+               });
+
+               const apiToolCalls = buildApiToolCalls(message);
+
+               // The bug: even though arguments are invalid, the outer array parses and
+               // the request gets sent. Function arguments must be parseable JSON on their
+               // own for the server to execute the tool.
+               expect(apiToolCalls).toBeDefined();
+               const args = (apiToolCalls![0] as { function: { arguments: string } }).function.arguments;
+               expect(() => JSON.parse(args)).toThrow();
+       });
+
+       // After Stop, savePartialResponseIfNeeded clears toolCalls and the agentic
+       // flow does the same in its silent-return detection. The next request reads
+       // toolCalls = '' and the conversion drops the field entirely so the server
+       // never sees the half-streamed call.
+       it('drops tool_calls from the API request after toolCalls is cleared', () => {
+               const clearedMessage = makeAssistant({
+                       content: 'partial reasoning',
+                       toolCalls: ''
+               });
+
+               const apiToolCalls = buildApiToolCalls(clearedMessage);
+               expect(apiToolCalls).toBeUndefined();
+       });
+
+       // The cleanup path keeps the partial reasoning content visible in the UI;
+       // only the tool_calls field is reset. deriveAgenticSections should still
+       // surface the reasoning as interrupted (no content / no tool calls behind
+       // it) without resurrecting the dead tool call block.
+       it('keeps reasoning content visible after cleanup, without a tool call block', () => {
+               const cleared = makeAssistant({
+                       content: '',
+                       reasoningContent: 'thinking about read_to',
+                       toolCalls: ''
+               });
+
+               const sections = deriveAgenticSections(cleared);
+               expect(sections).toHaveLength(1);
+               expect(sections[0].type).toBe('reasoning');
+               expect(sections.some((s) => s.type.includes('tool_call'))).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/unit/search-results-fixture.test.ts b/tools/ui/tests/unit/search-results-fixture.test.ts
new file mode 100644 (file)
index 0000000..6a84adf
--- /dev/null
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest';
+import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
+
+const SAMPLE = `Title: World Cup 2026 | Match schedule, fixtures, results & stadiums
+URL: https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums
+Published: 2026-06-22T00:01:00.000Z
+Author: N/A
+Highlights:
+Find out the full match schedule for World Cup 2026 in Canada, Mexico and USA with fixtures and results from each of the 104 games in the ...
+---
+Title: 2026 FIFA World Cup match schedule: Fixtures, results, features - ESPN
+URL: https://www.espn.com/soccer/story/_/id/48939282/2026-fifa-world-cup-fixtures-results-match-schedule-group-stage-knockout-rounds-bracket
+Published: 2026-07-08T07:07:00.000Z
+Author: ESPN
+Highlights:
+Round of 32 Â· Tuesday, July 7 Â· Argentina 3-2 Egypt (Atlanta) Switzerland (4) 0-0 (3) Colombia (Vancouver, Canada) Â· Monday, July 6 Â· Portugal 0-1 ...
+---
+Title: BBC
+URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
+Published: N/A
+Author: N/A
+Highlights:
+Something
+# World Cup
+...`;
+
+const QUERY_ARGS = '{"query":"FIFA World Cup 2026 schedule"}';
+
+describe('real-world Exa fixture', () => {
+       it('extracts every search result and preserves rich highlights', () => {
+               const results = extractSearchResults(SAMPLE);
+               expect(results.length).toBe(3);
+               expect(results[0].title).toContain('World Cup 2026 | Match schedule');
+               expect(results[0].url).toContain('fifa.com');
+               expect(results[1].title).toContain('2026 FIFA World Cup match schedule');
+               expect(results[1].author).toBe('ESPN');
+               expect(results[1].highlights).toContain('Round of 32');
+               expect(results[2].title).toBe('BBC');
+       });
+
+       it('parses the query out of the tool-args JSON', () => {
+               expect(extractSearchQuery(QUERY_ARGS)).toBe('FIFA World Cup 2026 schedule');
+       });
+});
diff --git a/tools/ui/tests/unit/search-results.test.ts b/tools/ui/tests/unit/search-results.test.ts
new file mode 100644 (file)
index 0000000..e2972ea
--- /dev/null
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest';
+import {
+       extractSearchResults,
+       extractSearchQuery,
+       faviconForUrl,
+       isWebSearchToolName
+} from '$lib/utils/search-results';
+
+describe('extractSearchResults', () => {
+       it('parses the Exa fixture with multiple results', () => {
+               const fixture = `Title: World Cup 2026 | Match schedule, fixtures
+URL: https://www.fifa.com/articles/match-schedule
+Published: 2026-06-22T00:01:00.000Z
+Author: N/A
+Highlights:
+Find out the full match schedule for World Cup 2026
+---
+Title: 2026 FIFA World Cup match schedule
+URL: https://www.espn.com/soccer/story/abc/def
+Published: 2026-07-08T07:07:00.000Z
+Author: ESPN
+Highlights:
+Round of 32 Â· Tuesday, July 7
+---
+Title: BBC
+URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
+Published: N/A
+Author: N/A
+Highlights:
+# FIFA World Cup Schedule
+...`;
+               const results = extractSearchResults(fixture);
+               expect(results.length).toBe(3);
+               expect(results[0].title).toContain('World Cup 2026');
+               expect(results[0].url).toBe('https://www.fifa.com/articles/match-schedule');
+               expect(results[0].published).toBe('2026-06-22T00:01:00.000Z');
+               // N/A filtered out
+               expect(results[0].author).toBeUndefined();
+               expect(results[0].highlights).toContain('match schedule');
+               expect(results[1].author).toBe('ESPN');
+               expect(results[2].author).toBeUndefined(); // N/A filtered
+       });
+
+       it('returns empty array for empty input', () => {
+               expect(extractSearchResults('')).toEqual([]);
+               expect(extractSearchResults(undefined)).toEqual([]);
+               expect(extractSearchResults(null)).toEqual([]);
+       });
+
+       it('skips chunks missing title or url', () => {
+               const txt = `Title: no url here
+Highlights:
+foo
+---
+Title: foo
+URL: https://x.com
+---
+just a paragraph
+---
+Title: b
+URL: not a url`;
+               const results = extractSearchResults(txt);
+               // Only middle one should pass (has title + url).
+               expect(results.length).toBe(1);
+               expect(results[0].url).toBe('https://x.com');
+       });
+
+       it('parses a single result without separators', () => {
+               const txt = `Title: only one
+URL: https://example.com/test
+Published: 2026-01-01T00:00:00Z
+Author: alice
+Highlights:
+a highlight`;
+               const results = extractSearchResults(txt);
+               expect(results.length).toBe(1);
+               expect(results[0].title).toBe('only one');
+               expect(results[0].author).toBe('alice');
+               expect(results[0].highlights).toBe('a highlight');
+       });
+
+       it('extracts query from JSON toolArgs', () => {
+               expect(extractSearchQuery('{"query":"foo"}')).toBe('foo');
+               expect(extractSearchQuery('  {"query":"  foo  "}  ')).toBe('foo');
+               expect(extractSearchQuery('not json')).toBe('');
+               expect(extractSearchQuery(null)).toBe('');
+               expect(extractSearchQuery('{"query":123}')).toBe('');
+       });
+
+       it('resolves favicon URLs from origins', () => {
+               expect(faviconForUrl('https://example.com/path/to/page')).toBe(
+                       'https://example.com/favicon.ico'
+               );
+               expect(faviconForUrl('http://example.com/x')).toBe('http://example.com/favicon.ico');
+               expect(faviconForUrl('not a url')).toBeNull();
+       });
+});
+
+describe('isWebSearchToolName', () => {
+       it('excludes tools that take the same query argument but are not web searches', () => {
+               expect(isWebSearchToolName('search_pull_requests')).toBe(false);
+               expect(isWebSearchToolName('search_code')).toBe(false);
+               expect(isWebSearchToolName('search_repositories')).toBe(false);
+               expect(isWebSearchToolName('search_issues')).toBe(false);
+       });
+
+       it('handles empty / missing input', () => {
+               expect(isWebSearchToolName(null)).toBe(false);
+               expect(isWebSearchToolName(undefined)).toBe(false);
+               expect(isWebSearchToolName('')).toBe(false);
+       });
+
+       it('returns false for unrelated tools', () => {
+               expect(isWebSearchToolName('web_fetch')).toBe(false);
+               expect(isWebSearchToolName('read_file')).toBe(false);
+               expect(isWebSearchToolName('exec_shell_command')).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/unit/sse.test.ts b/tools/ui/tests/unit/sse.test.ts
new file mode 100644 (file)
index 0000000..741d107
--- /dev/null
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { parseSseJsonStream } from '$lib/utils/sse';
+
+function makeSseResponse(events: string[]): Response {
+       const body = events.join('\n\n') + '\n\n';
+       return new Response(body, {
+               status: 200,
+               headers: { 'content-type': 'text/event-stream' }
+       });
+}
+
+describe('parseSseJsonStream', () => {
+       it('yields parsed data for each record', async () => {
+               const response = makeSseResponse(['data: {"chunk": "a"}', 'data: {"chunk": "b"}']);
+               const collected: unknown[] = [];
+               for await (const ev of parseSseJsonStream(response)) {
+                       collected.push(ev.data);
+               }
+               expect(collected).toEqual([{ chunk: 'a' }, { chunk: 'b' }]);
+       });
+
+       it('stops on [DONE] sentinel', async () => {
+               const response = makeSseResponse([
+                       'data: {"chunk": "a"}',
+                       'data: [DONE]',
+                       'data: {"chunk": "after-done"}'
+               ]);
+               const collected: unknown[] = [];
+               for await (const ev of parseSseJsonStream(response)) {
+                       collected.push(ev.data);
+               }
+               expect(collected).toEqual([{ chunk: 'a' }]);
+       });
+
+       it('skips malformed JSON records', async () => {
+               const response = makeSseResponse([
+                       'data: {"chunk": "ok"}',
+                       'data: {not-json}',
+                       'data: {"chunk": "also-ok"}'
+               ]);
+               const collected: unknown[] = [];
+               for await (const ev of parseSseJsonStream(response)) {
+                       collected.push(ev.data);
+               }
+               expect(collected).toEqual([{ chunk: 'ok' }, { chunk: 'also-ok' }]);
+       });
+
+       it('handles records split across multiple chunks (partial last line)', async () => {
+               const full = 'data: {"chunk": "x"}\n\ndata: {"chunk": "y"}\n\n';
+               const stream = new ReadableStream<Uint8Array>({
+                       start(controller) {
+                               const enc = new TextEncoder();
+                               controller.enqueue(enc.encode(full.slice(0, full.length / 2)));
+                               controller.enqueue(enc.encode(full.slice(full.length / 2)));
+                               controller.close();
+                       }
+               });
+               const response = new Response(stream, {
+                       status: 200,
+                       headers: { 'content-type': 'text/event-stream' }
+               });
+               const collected: unknown[] = [];
+               for await (const ev of parseSseJsonStream(response)) {
+                       collected.push(ev.data);
+               }
+               expect(collected).toEqual([{ chunk: 'x' }, { chunk: 'y' }]);
+       });
+
+       it('returns immediately if response has no body', async () => {
+               const response = new Response(null, { status: 200 });
+               const collected: unknown[] = [];
+               for await (const ev of parseSseJsonStream(response)) {
+                       collected.push(ev.data);
+               }
+               expect(collected).toEqual([]);
+       });
+});
diff --git a/tools/ui/tests/unit/tool-call-meta.test.ts b/tools/ui/tests/unit/tool-call-meta.test.ts
new file mode 100644 (file)
index 0000000..d3184e4
--- /dev/null
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import { tryParseToolResultObject } from '$lib/utils';
+
+describe('tryParseToolResultObject', () => {
+       it('returns null when no result is provided', () => {
+               expect(tryParseToolResultObject(undefined)).toBeNull();
+               expect(tryParseToolResultObject('')).toBeNull();
+       });
+
+       it('returns the parsed object when the result is JSON', () => {
+               expect(tryParseToolResultObject('{"result":"ok","bytes":42}')).toEqual({
+                       result: 'ok',
+                       bytes: 42
+               });
+       });
+
+       it('returns null for JSON arrays (only objects are useful to callers)', () => {
+               expect(tryParseToolResultObject('[1,2,3]')).toBeNull();
+       });
+
+       it('returns null for JSON primitives', () => {
+               expect(tryParseToolResultObject('"raw string"')).toBeNull();
+               expect(tryParseToolResultObject('42')).toBeNull();
+       });
+
+       it('returns null for invalid JSON', () => {
+               expect(tryParseToolResultObject('not json')).toBeNull();
+               expect(tryParseToolResultObject('{bad')).toBeNull();
+       });
+});
diff --git a/tools/ui/tests/unit/tool-calls.test.ts b/tools/ui/tests/unit/tool-calls.test.ts
new file mode 100644 (file)
index 0000000..9c54d97
--- /dev/null
@@ -0,0 +1,416 @@
+import { describe, expect, it } from 'vitest';
+import { AgenticSectionType, BuiltInTool } from '$lib/enums';
+import type { AgenticSection } from '$lib/utils';
+import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
+import {
+       parseWriteFileMeta,
+       type WriteFileMeta
+} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
+import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
+import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
+import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
+import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
+import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
+import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
+
+function makeSection(
+       overrides: Partial<AgenticSection> = {},
+       toolName = BuiltInTool.READ_FILE
+): AgenticSection {
+       return {
+               type: AgenticSectionType.TOOL_CALL,
+               content: '',
+               toolName,
+               toolArgs: JSON.stringify({ path: '/foo.txt' }),
+               toolResult: undefined,
+               ...overrides
+       };
+}
+
+describe('parseToolArgs (shared)', () => {
+       it('returns null when the section has no toolArgs', () => {
+               const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
+               expect(result).toBeNull();
+       });
+
+       it('returns null when the tool name does not match', () => {
+               const result = parseToolArgs(
+                       BuiltInTool.READ_FILE,
+                       makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.WRITE_FILE)
+               );
+               expect(result).toBeNull();
+       });
+
+       it('returns null when args are not valid final JSON (partial: false)', () => {
+               const result = parseToolArgs(
+                       BuiltInTool.READ_FILE,
+                       makeSection({ toolArgs: '{"path": "/foo.tx' })
+               );
+               expect(result).toBeNull();
+       });
+
+       it('returns parsed args when valid final JSON', () => {
+               const result = parseToolArgs(
+                       BuiltInTool.READ_FILE,
+                       makeSection({ toolArgs: '{"path":"/foo.txt"}' })
+               );
+               expect(result).toEqual({ path: '/foo.txt' });
+       });
+
+       it('accepts partial JSON when partial: true', () => {
+               const result = parseToolArgs(
+                       BuiltInTool.READ_FILE,
+                       makeSection({ toolArgs: '{"path": "/foo.tx' }),
+                       { partial: true }
+               );
+               expect(result).toEqual({ path: '/foo.tx' });
+       });
+});
+
+describe('parseWriteFileMeta', () => {
+       it('returns null for sections with a different tool name', () => {
+               expect(
+                       parseWriteFileMeta(
+                               makeSection({ toolName: BuiltInTool.READ_FILE, toolArgs: '{"path":"/x","content":"y"}' })
+                       )
+               ).toBeNull();
+       });
+
+       it('returns null when args have no path-like field', () => {
+               expect(
+                       parseWriteFileMeta(
+                               makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"content":"x"}' })
+                       )
+               ).toBeNull();
+       });
+
+       it('accepts partial args (renders incrementally as content streams in)', () => {
+               const meta = parseWriteFileMeta(
+                       makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"path":"/foo.t' })
+               );
+               expect(meta?.filePath).toBe('/foo.t');
+       });
+
+       it('returns file path, language, content, bytes, resultMessage', () => {
+               const meta = parseWriteFileMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.WRITE_FILE,
+                                       toolArgs: '{"path":"/foo.ts","content":"x"}',
+                                       toolResult: '{"result":"wrote","bytes":42}'
+                               },
+                               BuiltInTool.WRITE_FILE
+                       )
+               );
+               expect(meta).toMatchObject<Partial<WriteFileMeta>>({
+                       filePath: '/foo.ts',
+                       language: expect.any(String),
+                       content: 'x',
+                       bytesWritten: 42,
+                       resultMessage: 'wrote'
+               });
+       });
+
+       it('surfaces errorMessage from the result blob', () => {
+               const meta = parseWriteFileMeta(
+                       makeSection({
+                               toolName: BuiltInTool.WRITE_FILE,
+                               toolArgs: '{"path":"/foo","content":"x"}',
+                               toolResult: '{"error":"permission denied"}'
+                       })
+               );
+               expect(meta?.errorMessage).toBe('permission denied');
+       });
+});
+
+describe('parseEditFileMeta', () => {
+       it('parses edits array and applies editsApplied from the result', () => {
+               const section = makeSection(
+                       {
+                               toolName: BuiltInTool.EDIT_FILE,
+                               toolArgs:
+                                       '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
+                               toolResult: '{"result":"ok","edits_applied":2}'
+                       },
+                       BuiltInTool.EDIT_FILE
+               );
+               const meta = parseEditFileMeta(section);
+               expect(meta?.edits).toEqual([
+                       { oldText: 'a', newText: 'b' },
+                       { oldText: 'c', newText: 'd' }
+               ]);
+               expect(meta?.editsApplied).toBe(2);
+               expect(meta?.resultMessage).toBe('ok');
+       });
+
+       it('drops edits with empty old_text', () => {
+               const section = makeSection(
+                       {
+                               toolName: BuiltInTool.EDIT_FILE,
+                               toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}'
+                       },
+                       BuiltInTool.EDIT_FILE
+               );
+               const meta = parseEditFileMeta(section);
+               // First entry is dropped (empty old_text). Second is kept
+               // (empty new_text is fine - it's the "delete" case).
+               expect(meta?.edits).toEqual([{ oldText: 'a', newText: '' }]);
+       });
+
+       it('errorMessage wins over result message', () => {
+               const section = makeSection(
+                       {
+                               toolName: BuiltInTool.EDIT_FILE,
+                               toolArgs: '{"path":"/foo"}',
+                               toolResult: '{"error":"bad path","result":"ok"}'
+                       },
+                       BuiltInTool.EDIT_FILE
+               );
+               const meta = parseEditFileMeta(section);
+               expect(meta?.errorMessage).toBe('bad path');
+               expect(meta?.resultMessage).toBeUndefined();
+       });
+});
+
+describe('parseReadFileMeta', () => {
+       it('parses file name alone (no range)', () => {
+               const meta = parseReadFileMeta(
+                       makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.READ_FILE)
+               );
+               expect(meta?.fileName).toBe('foo.txt');
+               expect(meta?.lineRange).toBeNull();
+       });
+
+       it('parses start_line + end_line into a range', () => {
+               const meta = parseReadFileMeta(
+                       makeSection(
+                               { toolArgs: '{"path":"/foo.ts","start_line":10,"end_line":20}' },
+                               BuiltInTool.READ_FILE
+                       )
+               );
+               expect(meta?.lineRange).toEqual({ start: 10, end: 20 });
+       });
+
+       it('parses start_line + line_count into a range', () => {
+               const meta = parseReadFileMeta(
+                       makeSection(
+                               { toolArgs: '{"path":"/foo.ts","start_line":10,"line_count":5}' },
+                               BuiltInTool.READ_FILE
+                       )
+               );
+               expect(meta?.lineRange).toEqual({ start: 10, end: 14 });
+       });
+
+       it('returns null when args cannot be parsed', () => {
+               expect(parseReadFileMeta(makeSection({ toolArgs: '{bad' }, BuiltInTool.READ_FILE))).toBeNull();
+       });
+});
+
+describe('parseGrepSearchMeta', () => {
+       it('returns null when path or pattern is missing', () => {
+               expect(
+                       parseGrepSearchMeta(
+                               makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"pattern":"foo"}' })
+                       )
+               ).toBeNull();
+               expect(
+                       parseGrepSearchMeta(
+                               makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"path":"/x"}' })
+                       )
+               ).toBeNull();
+       });
+
+       it('parses structured plain_text_response into matches', () => {
+               const meta = parseGrepSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.GREP_SEARCH,
+                                       toolArgs: '{"path":"/x","pattern":"foo"}',
+                                       toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
+                               },
+                               BuiltInTool.GREP_SEARCH
+                       )
+               );
+               expect(meta?.matches).toHaveLength(2);
+               expect(meta?.matches[0]).toEqual({ file: 'a.ts', content: 'hello' });
+       });
+
+       it('falls back to raw-text parsing when result is not JSON', () => {
+               const meta = parseGrepSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.GREP_SEARCH,
+                                       toolArgs: '{"path":"/x","pattern":"foo"}',
+                                       toolResult: 'a.ts:hello\nb.ts:world'
+                               },
+                               BuiltInTool.GREP_SEARCH
+                       )
+               );
+               expect(meta?.matches).toHaveLength(2);
+       });
+
+       it('parses line numbers when return_line_numbers is true', () => {
+               const meta = parseGrepSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.GREP_SEARCH,
+                                       toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
+                                       toolResult: 'a.ts:12:hello'
+                               },
+                               BuiltInTool.GREP_SEARCH
+                       )
+               );
+               expect(meta?.matches[0]).toEqual({ file: 'a.ts', line: 12, content: 'hello' });
+               expect(meta?.showLineNumbers).toBe(true);
+       });
+});
+
+describe('parseFileGlobSearchMeta', () => {
+       it('falls back to raw-text parsing when result is not JSON', () => {
+               const meta = parseFileGlobSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.FILE_GLOB_SEARCH,
+                                       toolArgs: '{"path":"/x"}',
+                                       toolResult: 'a.ts\nb.ts'
+                               },
+                               BuiltInTool.FILE_GLOB_SEARCH
+                       )
+               );
+               expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
+       });
+
+       it('parses plain_text_response from a JSON object', () => {
+               const meta = parseFileGlobSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.FILE_GLOB_SEARCH,
+                                       toolArgs: '{"path":"/x"}',
+                                       toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
+                               },
+                               BuiltInTool.FILE_GLOB_SEARCH
+                       )
+               );
+               expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
+       });
+
+       it('surfaces errorMessage from the result blob', () => {
+               const meta = parseFileGlobSearchMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.FILE_GLOB_SEARCH,
+                                       toolArgs: '{"path":"/x"}',
+                                       toolResult: JSON.stringify({ error: 'permission denied' })
+                               },
+                               BuiltInTool.FILE_GLOB_SEARCH
+                       )
+               );
+               expect(meta?.errorMessage).toBe('permission denied');
+       });
+});
+
+describe('parseRunJavascriptMeta', () => {
+       it('returns null when code is missing', () => {
+               expect(
+                       parseRunJavascriptMeta(makeSection({ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{}' }))
+               ).toBeNull();
+       });
+
+       it('reads code and timeout', () => {
+               const meta = parseRunJavascriptMeta(
+                       makeSection(
+                               { toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{"code":"Math.PI","timeout_ms":5000}' },
+                               BuiltInTool.RUN_JAVASCRIPT
+                       )
+               );
+               expect(meta?.code).toBe('Math.PI');
+               expect(meta?.timeoutMs).toBe(5000);
+       });
+
+       it('reads error field from a JSON-object result', () => {
+               const meta = parseRunJavascriptMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.RUN_JAVASCRIPT,
+                                       toolArgs: '{"code":"throw new Error()"}',
+                                       toolResult: JSON.stringify({ error: 'undefined is not a function' })
+                               },
+                               BuiltInTool.RUN_JAVASCRIPT
+                       )
+               );
+               expect(meta?.errorMessage).toBe('undefined is not a function');
+       });
+
+       it('does NOT treat a JSON-array result as an error', () => {
+               // SandboxService returns successful output as a JSON array;
+               // only JSON objects carry `error`. Raw arrays must round-trip
+               // through unchanged.
+               const meta = parseRunJavascriptMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.RUN_JAVASCRIPT,
+                                       toolArgs: '{"code":"[1,2,3]"}',
+                                       toolResult: '[1,2,3]'
+                               },
+                               BuiltInTool.RUN_JAVASCRIPT
+                       )
+               );
+               expect(meta?.errorMessage).toBeUndefined();
+       });
+
+       it('scans a non-JSON string result for an `Error:` line', () => {
+               const meta = parseRunJavascriptMeta(
+                       makeSection(
+                               {
+                                       toolName: BuiltInTool.RUN_JAVASCRIPT,
+                                       toolArgs: '{"code":"foo"}',
+                                       toolResult: 'Error: undefined is not a function\n  at <anonymous>:1:1'
+                               },
+                               BuiltInTool.RUN_JAVASCRIPT
+                       )
+               );
+               expect(meta?.errorMessage).toBe('undefined is not a function');
+       });
+});
+
+describe('parseExecShellCommandMeta', () => {
+       it('reads command from the args', () => {
+               const meta = parseExecShellCommandMeta(
+                       makeSection(
+                               { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"command":"ls -la"}' },
+                               BuiltInTool.EXEC_SHELL_COMMAND
+                       )
+               );
+               expect(meta?.command).toBe('ls -la');
+       });
+
+       it('accepts cmd / shell_command aliases', () => {
+               expect(
+                       parseExecShellCommandMeta(
+                               makeSection(
+                                       { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cmd":"ls"}' },
+                                       BuiltInTool.EXEC_SHELL_COMMAND
+                               )
+                       )?.command
+               ).toBe('ls');
+               expect(
+                       parseExecShellCommandMeta(
+                               makeSection(
+                                       { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"shell_command":"ls"}' },
+                                       BuiltInTool.EXEC_SHELL_COMMAND
+                               )
+                       )?.command
+               ).toBe('ls');
+       });
+
+       it('returns null when no command alias is present', () => {
+               expect(
+                       parseExecShellCommandMeta(
+                               makeSection(
+                                       { toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cwd":"/x"}' },
+                                       BuiltInTool.EXEC_SHELL_COMMAND
+                               )
+                       )
+               ).toBeNull();
+       });
+});