// 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'
}
-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 {
<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';
<ActionIcon
icon={Copy}
tooltip={ariaLabel}
- iconSize="h-4 w-4"
+ iconSize={ICON_CLASS_DEFAULT}
disabled={!canCopy}
onclick={() => canCopy && copyToClipboard(text)}
/>
<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}
<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';
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">
<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>
<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';
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
- <Plus class="h-4 w-4" />
+ <Plus class={ICON_CLASS_DEFAULT} />
</Button>
</Tooltip.Trigger>
<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';
>
<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>
<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';
<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>
<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>
<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';
<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
}}
>
{#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>
<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>
<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';
<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
</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
<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>
<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>
<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';
<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>
<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}
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,
<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}
{isLastAssistantMessage}
{message}
{toolMessages}
- messageContent={message.content}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}
onCopy={handleCopy}
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';
isLastAssistantMessage?: boolean;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
- messageContent: string | undefined;
onCopy: () => void;
onConfirmDelete: () => void;
onContinue?: () => void;
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>
--- /dev/null
+<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}
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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}
--- /dev/null
+<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}
--- /dev/null
+<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>
--- /dev/null
+<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} of {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'} 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>
--- /dev/null
+<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">·</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>
--- /dev/null
+<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'} </span
+ >
+ {#if fileGlobMeta.include !== '**'}
+ <span class="font-mono">{fileGlobMeta.include}</span>
+ {/if}
+ <span class="text-muted-foreground"> in </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>
--- /dev/null
+<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 </span>
+ <span class="text-red-600 text-xs italic dark:text-red-400">- {dateMeta.errorMessage}</span
+ >
+ {:else if dateMeta.dateString}
+ <span class="text-foreground/80 text-sm font-medium">Current time is </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>
--- /dev/null
+<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 </span>
+ <span class="font-mono">{grepMeta.pattern}</span>
+ <span class="text-muted-foreground"> in </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}
+ <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>
--- /dev/null
+<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"
+ > (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>
--- /dev/null
+<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">· timeout {meta.timeoutMs} 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>
--- /dev/null
+<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">·</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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+// 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);
+}
--- /dev/null
+// 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
+ };
+}
--- /dev/null
+// 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 };
+}
--- /dev/null
+// 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 };
+}
--- /dev/null
+// 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)
+ };
+}
--- /dev/null
+// 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 };
+}
--- /dev/null
+// 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 };
+}
--- /dev/null
+// 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
+ };
+}
<script lang="ts">
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet, Component } from 'svelte';
interface Props {
<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>
<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
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(*),
--- /dev/null
+<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>
<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';
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>
<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';
>
<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">
* 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
<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>
--- /dev/null
+<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>
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 */
<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';
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"
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>
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>
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>
<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;
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, '&').replace(/</g, '<').replace(/>/g, '>');
- }
+ $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>
* ```svelte
* <CollapsibleContentBlock
* bind:open
- * icon={BrainIcon}
* title="Thinking..."
* isStreaming
* >
*/
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
*
<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'}
<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>
<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';
>
<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}
<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>
<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';
<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
onclick={handleClear}
aria-label={value ? 'Clear search' : 'Close'}
>
- <X class="h-4 w-4" />
+ <X class={ICON_CLASS_DEFAULT} />
</button>
{/if}
</div>
<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';
>
<Tooltip.Root>
<Tooltip.Trigger>
- <McpLogo class="h-4 w-4" />
+ <McpLogo class={ICON_CLASS_DEFAULT} />
</Tooltip.Trigger>
<Tooltip.Content>
<img
src={favicon.url}
alt=""
- class="h-4 w-4"
+ class={ICON_CLASS_DEFAULT}
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
<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>
<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';
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>
<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>
<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>
--- /dev/null
+<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>
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 {
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());
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}
/** 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)
*
<script lang="ts">
+ import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import type { Snippet } from 'svelte';
disabled={!canScrollLeft}
aria-label="Scroll left"
>
- <ChevronLeft class="h-4 w-4" />
+ <ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
disabled={!canScrollRight}
aria-label="Scroll right"
>
- <ChevronRight class="h-4 w-4" />
+ <ChevronRight class={ICON_CLASS_DEFAULT} />
</button>
</div>
<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">
<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';
</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'
: ''}"
<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"
<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';
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';
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}
<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';
{#if showActions && error}
<Button variant="outline" size="sm" class="text-destructive">
- <AlertTriangle class="h-4 w-4" />
+ <AlertTriangle class={ICON_CLASS_DEFAULT} />
{error}
</Button>
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';
<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}
<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';
<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>
<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';
<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}
<Checkbox
checked={isEnabled}
onCheckedChange={() => toolsStore.toggleTool(entry.key)}
- class="h-4 w-4"
+ class={ICON_CLASS_DEFAULT}
/>
</div>
permissionsStore.allowTool(permissionKey);
}
}}
- class="h-4 w-4"
+ class={ICON_CLASS_DEFAULT}
/>
</div>
</div>
<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[];
: '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}
: '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}
<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 {
onclick={carousel.scrollLeft}
aria-label="Scroll left"
>
- <ChevronLeft class="h-4 w-4" />
+ <ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
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}
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>
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);
}
}}
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,
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;
--- /dev/null
+// 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;
+}
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:/;
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';
/** 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 */
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';
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';
['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',
--- /dev/null
+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'
+ }
+];
-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;
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',
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: {
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',
/** 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) => ({
type: s.type,
isExperimental: s.isExperimental,
isPositiveInteger: s.isPositiveInteger,
+ dependsOn: s.dependsOn,
help: s.help,
options: s.options
}))
}));
export const SETTINGS_FALLBACK_EXIT_ROUTE = ROUTES.START;
-
-export { SETTINGS_KEYS } from './settings-keys';
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.`;
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;
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 = /\/+$/;
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'
+}
AttachmentItemVisibleWhen
} from './attachment.enums';
-export { AgenticSectionType, ContinueIntentKind, ToolCallType } from './agentic.enums';
+export {
+ AgenticSectionType,
+ ContinueIntentKind,
+ DiffLineKind,
+ ToolResultKind,
+ ToolCallType
+} from './agentic.enums';
export {
ChatMessageStatsView,
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';
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'
+}
+++ /dev/null
-/**
- * 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;
- }
- };
-}
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
import {
- NEWLINE_SEPARATOR,
+ NEWLINE,
SANDBOX_EMPTY_OUTPUT,
SANDBOX_OUTPUT_MAX_CHARS,
SANDBOX_TIMEOUT_MS_DEFAULT,
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 };
+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';
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}`;
}
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,
totalToolCalls: 0,
lastError: null,
streamingToolCall: null,
- pendingPermissionRequest: null
+ pendingPermissionRequest: null,
+ executingToolCallId: null
};
}
}
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(
onCompletionId,
onAssistantTurnComplete,
createToolResultMessage,
+ updateToolResultMessage,
createAssistantMessage,
onFlowComplete,
onTimings,
onToolCallChunk: (serialized: string) => {
try {
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
+
onToolCallsStreaming?.(turnToolCalls);
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
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)) {
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);
}
} 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,
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,
return { cleanedResult: result, attachments: [] };
}
- const lines = result.split(NEWLINE_SEPARATOR);
+ const lines = result.split(NEWLINE);
const attachments: DatabaseMessageExtra[] = [];
let attachmentIndex = 0;
return line;
});
- return { cleanedResult: cleanedLines.join(NEWLINE_SEPARATOR), attachments };
+ return { cleanedResult: cleanedLines.join(NEWLINE), attachments };
}
private buildAttachmentName(mimeType: string, index: number): string {
export function agenticIsAnyRunning() {
return agenticStore.isAnyRunning;
}
+
+export function agenticExecutingToolCallId(conversationId: string) {
+ return agenticStore.executingToolCallId(conversationId);
+}
* 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();
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 = '';
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 = {
}
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);
}
}
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';
name: resourceName,
uri: attachment.resource.uri,
serverName: attachment.resource.serverName,
- content: contentParts.join(NEWLINE_SEPARATOR),
+ content: contentParts.join(NEWLINE),
mimeType: attachment.resource.mimeType
});
}
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;
}
}
clearHealthCheck(serverId: string): void {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [serverId]: _removed, ...rest } = this._healthChecks;
this._healthChecks = rest;
}
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) {
* 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
}
}
- 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;
}
* 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
}
}
- 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;
}
}
/**
- * 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);
}
// 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 &&
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;
}
/**
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 */
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;
MCPClientConfig,
MCPServerSettingsEntry,
MCPServerDisplayInfo,
+ RecommendedMCPServer,
MCPToolCall,
OpenAIToolDefinition,
ServerStatus,
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;
options?: Array<{ value: string; label: string; icon: Component }>;
isExperimental?: boolean;
isPositiveInteger?: boolean;
+ dependsOn?: string;
sync?: {
serverKey: string;
paramType: SyncableParameterType;
type: SettingsFieldType;
isExperimental?: boolean;
isPositiveInteger?: boolean;
+ dependsOn?: string;
help?: string;
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
}
-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
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;
}
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
- toolResultExtras: resultMsg?.extra
+ toolResultExtras: resultMsg?.extra,
+ toolCallId: tc.id
});
}
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: tc.function?.name,
- toolArgs: tc.function?.arguments
+ toolArgs: tc.function?.arguments,
+ toolCallId: tc.id
});
}
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`.
*/
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.
*/
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 };
});
}
+/**
+ * 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.
*/
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';
/**
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
- return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
+ return apiKey ? { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` } : {};
}
/**
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';
/**
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 });
AMPERSAND_REGEX,
LT_REGEX,
GT_REGEX,
- FENCE_PATTERN
+ FENCE_PATTERN,
+ TRIM_LEADING_PADDING_REGEX,
+ TRIM_TRAILING_PADDING_REGEX
} from '$lib/constants';
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
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, '&')
.replace(LT_REGEX, '<')
.replace(GT_REGEX, '>');
}
}
+export { trimCodePadding };
+
/**
* Detects if markdown ends with an incomplete code block (opened but not closed).
* Returns the code block info if found, null otherwise.
--- /dev/null
+/**
+ * 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));
+}
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';
/**
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 };
-}
} 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';
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';
formatJsonPretty,
formatTime,
formatPerformanceTime,
- formatAttachmentText,
- formatReasoningPreview
+ formatAttachmentText
} from './formatters';
// IME utilities
// 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,
// 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';
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';
+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.
*
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
--- /dev/null
+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;
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+// 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;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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);
+}
--- /dev/null
+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);
+ }
+ }
+}
-import { NEWLINE_SEPARATOR } from '$lib/constants';
+import { NEWLINE } from '$lib/constants';
/**
* Returns a shortened preview of the provided content capped at the given length.
*/
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();
}
--- /dev/null
+// 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;
+ }
+}
-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';
/**
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, '');
+ }
+}
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);
});
});
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',
--- /dev/null
+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')
+ );
+ });
+});
--- /dev/null
+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');
+ });
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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
+ });
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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([]);
+ });
+});
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+ });
+});