include llama.cpp since there were some edits

This commit is contained in:
zefie committed 2026-11-01 16:32:35 -05:00
1 parent db7f28d087
commit 8c66990853
3918 files changed
+1640170 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
!*
+284
View File
@@ -0,0 +1,284 @@
@import 'tailwindcss';
@source '.';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
/* shadcn components key their orientation styles off these, toggle-group among them */
@custom-variant data-horizontal (&[data-orientation='horizontal']);
@custom-variant data-vertical (&[data-orientation='vertical']);
/*
* Tailwind v4 dropped the pointer cursor from buttons. Every control a user can
* press gets it back, and anything disabled keeps the cursor its own class asks for.
*/
@layer base {
button:not(:disabled),
[role='button']:not([data-disabled]),
[role='checkbox']:not([data-disabled]),
[role='combobox']:not([data-disabled]),
[role='menuitem']:not([data-disabled]),
[role='menuitemcheckbox']:not([data-disabled]),
[role='menuitemradio']:not([data-disabled]),
[role='option']:not([data-disabled]),
[role='radio']:not([data-disabled]),
[role='switch']:not([data-disabled]),
[role='tab']:not([data-disabled]),
label[for],
select,
summary {
cursor: pointer;
}
}
:root {
--radius: 0.625rem;
/* expanded desktop sidebar width, overridden once the user drags its edge */
--sidebar-width: 18rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.95 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.95 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.875 0 0);
--input: oklch(0.92 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--code-background: oklch(0.985 0 0);
--code-foreground: oklch(0.145 0 0);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
--layer-popover: 1000000;
--chat-form-area-height: 8rem;
--chat-form-area-offset: 2rem;
--chat-form-padding-top: 6rem;
--max-message-height: max(24rem, min(80dvh, calc(100dvh - var(--chat-form-area-height) - 12rem)));
}
@media (min-width: 640px) {
:root {
--chat-form-area-height: 24rem;
--chat-form-area-offset: 12rem;
--chat-form-padding-top: 6rem;
}
}
.dark {
--background: oklch(0.16 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.29 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 30%);
--input: oklch(1 0 0 / 30%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.2 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--code-background: oklch(0.225 0 0);
--code-foreground: oklch(0.875 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
/* no elastic bounce past the ends of the page: it drags the sticky and
fixed bars (tab bar, chat form) along and they jump back on release */
html,
body {
overscroll-behavior: none;
}
/* same for dialogs, whose panels and lists scroll on their own with sticky
headers and footers. Scroll containers only: Firefox treats an
overflow-hidden box (truncated text) as one, and with `none` it would
keep the wheel from reaching the list around it */
:is([role='dialog'], [role='alertdialog']),
:is([role='dialog'], [role='alertdialog'])
:is(.overflow-auto, .overflow-y-auto, [data-slot='scroll-area-viewport']) {
overscroll-behavior: none;
}
/* Global scrollbar styling - visible only on hover */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
transition: background 0.2s ease;
}
*:hover::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
:where(code, pre, kbd, samp) {
font-family: var(--font-mono);
}
}
@layer utilities {
.scrollbar-hide {
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Fade at the top edge of the chat, under the tab bar or a sticky header */
.chat-top-fade {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--background) 100%, transparent) 25%,
color-mix(in srgb, var(--background) 80%, transparent) 50%,
color-mix(in srgb, var(--background) 40%, transparent) 75%,
transparent 100%
);
}
.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 {
display: none !important;
}
+143
View File
@@ -0,0 +1,143 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import 'vite-plugin-pwa/pwa-assets';
import 'vite-plugin-pwa/svelte';
import { ModelModality, ServerModelStatus, ServerRole } from '$lib/enums';
// Import chat types from dedicated module
import type {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageContentPart,
ApiChatMessageData,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelsSseProgress,
ApiModelsUnloadResponse,
ApiProcessingState,
ChatAttachmentDisplayItem,
// Chat types
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,
ChatMessageType,
ChatRole,
ChatUploadedFile,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraVideoFile,
ExportedConversation,
ExportedConversations,
ModelLoadProgress,
// Model types
ModelModalities,
ModelOption,
// Settings types
SettingsChatServiceOptions,
SettingsConfigType,
SettingsConfigValue,
SettingsFieldConfig
} from '$lib/types';
declare global {
// namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
// }
export {
// API types
ApiChatCompletionRequest,
ApiChatCompletionResponse,
ApiChatCompletionStreamChunk,
ApiChatCompletionToolCall,
ApiChatCompletionToolCallDelta,
ApiChatMessageData,
ApiChatMessageContentPart,
ApiContextSizeError,
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ModelDownloadProgress,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
ApiModelsSseEvent,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsUnloadResponse,
ApiProcessingState,
// Chat types
ChatAttachmentDisplayItem,
ChatMessagePromptProgress,
ChatMessageSiblingInfo,
ChatMessageTimings,
ChatMessageType,
ChatRole,
ChatUploadedFile,
// Database types
DatabaseConversation,
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraLegacyContext,
ExportedConversation,
ExportedConversations,
// Enum types
ModelModality,
ServerRole,
ServerModelStatus,
// Model types
ModelModalities,
ModelOption,
ModelLoadProgress,
// Settings types
SettingsChatServiceOptions,
SettingsConfigValue,
SettingsFieldConfig,
SettingsConfigType
};
}
declare global {
interface Window {
idxCodeBlock?: number;
// File System Access API - not in the DOM lib and unavailable in some browsers
showDirectoryPicker?: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
}) => Promise<FileSystemDirectoryHandle>;
}
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="favicon.ico" sizes="48x48" />
<link rel="icon" href="favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="apple-touch-icon-180x180.png" />
<link rel="manifest" href="./manifest.webmanifest" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,7 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M244.95 8C215.233 8 187.774 23.8591 172.923 49.5999L95.6009 183.625C60.2162 244.959 104.481 321.6 175.29 321.6H208L316.977 132.708C348.959 77.2719 308.95 8 244.95 8ZM208 321.6H351.947C415.982 321.6 456.013 390.91 424.013 446.377C409.155 472.132 381.681 488 351.947 488H271.29C200.481 488 156.216 411.359 191.601 350.026L208 321.6Z" fill="currentColor"/>
<path d="M208 321.6H16L106.462 164.8L208 321.6Z" fill="currentColor"/>
<path d="M388.923 8L208 321.6L253.6 8H388.923Z" fill="currentColor"/>
<path d="M304 488H112L202.462 331.2L304 488Z" fill="currentColor"/>
<path d="M496 321.6H208L419.399 454.4L496 321.6Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

@@ -0,0 +1,11 @@
---
name: app
description: Opinionated app components building on top of ./ui primitives
---
- Can include business logic and state management
- Can include data fetching and caching logic
- Should use original spelling for HTML-native events and `camelCase` for custom events
- Props and markup attributes should be listed alphabetically
- Use JS Objects and Arrays for CSS classes and styles when they are dynamic
- Whenever there can be repetition in the component's markup, if it's too small to be decoupled as a separate component — use Svelte 5's `{#snippet}` + `{@render}`
@@ -0,0 +1,91 @@
<script lang="ts">
import { Button, type ButtonSize, type ButtonVariant } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TooltipSide } from '$lib/enums';
import type { Component } from 'svelte';
interface Props {
ariaLabel?: string;
class?: string;
disabled?: boolean;
href?: string;
icon: Component;
iconSize?: string;
onclick?: (e?: MouseEvent) => void;
size?: ButtonSize;
stopPropagationOnClick?: boolean;
tooltip?: string;
tooltipAsTitle?: boolean;
tooltipSide?: TooltipSide;
variant?: ButtonVariant;
}
let {
ariaLabel,
class: className = '',
disabled = false,
href = '',
icon,
iconSize = 'h-3 w-3',
onclick,
size = 'sm',
stopPropagationOnClick = false,
tooltip,
tooltipAsTitle = false,
tooltipSide = TooltipSide.TOP,
variant = 'ghost'
}: Props = $props();
let innerWidth = $state(0);
const showTooltip = $derived(!!tooltip && !tooltipAsTitle && innerWidth > 768);
</script>
{#snippet button(props = {})}
<Button
{...props}
aria-label={ariaLabel || tooltip}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
{disabled}
{href}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
onclick?.(e);
}}
{size}
title={tooltipAsTitle ? tooltip : undefined}
{variant}
>
{#if icon}
{@const IconComponent = icon}
<IconComponent class={iconSize} />
{/if}
</Button>
{/snippet}
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- prevent another nested button element -->
{#snippet child({ props })}
{#if disabled}
<!-- disabled buttons have pointer-events:none; wrap in a span so the tooltip hover surface stays alive -->
<span {...props}>
{@render button({})}
</span>
{:else}
{@render button(props)}
{/if}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side={tooltipSide}>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render button({ href })}
{/if}
<svelte:window bind:innerWidth />
@@ -0,0 +1,18 @@
<script lang="ts">
import ActionIcon from './ActionIcon.svelte';
import { Copy } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { copyToClipboard } from '$lib/utils';
export let ariaLabel: string = 'Copy to clipboard';
export let canCopy: boolean = true;
export let text: string;
</script>
<ActionIcon
disabled={!canCopy}
icon={Copy}
iconSize={ICON_CLASS_DEFAULT}
onclick={() => canCopy && copyToClipboard(text)}
tooltip={ariaLabel}
/>
@@ -0,0 +1,13 @@
/**
*
* ACTIONS
*
* Small interactive components for user actions.
*
*/
/** Styled icon button for action triggers with tooltip. */
export { default as ActionIcon } from './ActionIcon.svelte';
/** Copy-to-clipboard icon button with clipboard logic. */
export { default as ActionIconCopyToClipboard } from './ActionIconCopyToClipboard.svelte';
@@ -0,0 +1,26 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
children: Snippet;
class?: string;
icon?: Snippet;
}
let { children, class: className = '', icon, ...rest }: Props = $props();
</script>
<button
{...rest}
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75',
className
]}
>
{#if icon}
{@render icon()}
{/if}
{@render children()}
</button>
@@ -0,0 +1,29 @@
<script lang="ts">
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { ModelModality } from '$lib/enums';
interface Props {
modalities: ModelModality[];
class?: string;
}
let { class: className = '', modalities }: Props = $props();
const shownModalities = [ModelModality.VISION, ModelModality.AUDIO, ModelModality.VIDEO] as const;
let visible = $derived(shownModalities.filter((modality) => modalities.includes(modality)));
</script>
{#each visible as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
]}
>
<ModalityIcon class="h-3 w-3" />
{MODALITY_LABELS[modality]}
</span>
{/each}
@@ -0,0 +1,13 @@
/**
*
* BADGES & INDICATORS
*
* Small visual indicators for status and metadata.
*
*/
/** Generic info badge with optional tooltip and click handler. */
export { default as BadgeInfo } from './BadgeInfo.svelte';
/** Badge indicating model modality (vision, audio, tools). */
export { default as BadgesModality } from './BadgesModality.svelte';
@@ -0,0 +1,120 @@
<script lang="ts">
import {
ChatAttachmentsListItem,
DialogChatAttachmentsPreview,
DialogMcpResourcePreview,
ScrollCarousel
} from '$lib/components/app';
import { ScrollCarouselVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
interface Props {
class?: string;
style?: string;
// For ChatMessage - stored attachments
attachments?: DatabaseMessageExtra[];
readonly?: boolean;
// For ChatForm - pending uploads
onFileRemove?: (fileId: string) => void;
uploadedFiles?: ChatUploadedFile[];
// Image size customization
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
// Limit display to single row with "+ X more" button
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
}
let {
activeModelId,
attachments = [],
class: className = '',
// Default to small size for form previews
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
onFileRemove,
readonly = false,
style = '',
uploadedFiles = $bindable([])
}: Props = $props();
let carouselRef: ScrollCarousel | undefined = $state();
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let previewFocusIndex = $state(0);
let viewAllDialogOpen = $state(false);
let displayItems = $derived(getAttachmentDisplayItems({ attachments, uploadedFiles }));
function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
// Find the index of the clicked item among non-MCP attachments
const nonMcpItems = displayItems.filter((i) => !isMcpPrompt(i) && !isMcpResource(i));
const index = nonMcpItems.findIndex((i) => i.id === item.id);
previewFocusIndex = index >= 0 ? index : 0;
viewAllDialogOpen = true;
}
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource) {
mcpResourcePreviewExtra = extra;
mcpResourcePreviewOpen = true;
}
$effect(() => {
if (carouselRef && displayItems.length) {
carouselRef.resetScroll();
}
});
</script>
{#snippet attachmentitem(item: ChatAttachmentDisplayItem)}
<ChatAttachmentsListItem
{imageClass}
{imageHeight}
{imageWidth}
{item}
{limitToSingleRow}
{onFileRemove}
onMcpResourcePreview={openMcpResourcePreview}
onPreview={(i: ChatAttachmentDisplayItem, event?: MouseEvent) => openPreview(i, event)}
{readonly}
/>
{/snippet}
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<ScrollCarousel bind:this={carouselRef} variant={ScrollCarouselVariant.CENTER}>
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</ScrollCarousel>
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</div>
{/if}
</div>
{/if}
<DialogChatAttachmentsPreview
bind:open={viewAllDialogOpen}
{activeModelId}
{attachments}
{previewFocusIndex}
{uploadedFiles}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}
@@ -0,0 +1,135 @@
<script lang="ts">
import {
ChatAttachmentsListItemMcpPrompt,
ChatAttachmentsListItemMcpResource,
ChatAttachmentsListItemThumbnailFile,
ChatAttachmentsListItemThumbnailImage
} from '$lib/components/app';
import { AttachmentType } from '$lib/enums';
import type {
ChatAttachmentDisplayItem,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraMcpResource,
MCPResourceAttachment
} from '$lib/types';
import { isMcpPrompt, isMcpResource, isPdfFile } from '$lib/utils';
interface Props {
class?: string;
imageClass?: string;
imageHeight?: string;
imageWidth?: string;
item: ChatAttachmentDisplayItem;
limitToSingleRow?: boolean;
onFileRemove?: (fileId: string) => void;
onMcpResourcePreview?: (extra: DatabaseMessageExtraMcpResource) => void;
onPreview?: (item: ChatAttachmentDisplayItem) => void;
readonly?: boolean;
}
let {
class: className = '',
imageClass = '',
imageHeight = 'h-24',
imageWidth = 'w-auto',
item,
limitToSingleRow = false,
onFileRemove,
onMcpResourcePreview,
onPreview,
readonly = false
}: Props = $props();
const scrollClasses = $derived(limitToSingleRow ? 'first:ml-4 last:mr-4' : '');
// Carousel items must keep their width; wrapped attachments (message bubbles)
// shrink so wide images fit the bubble instead of overflowing it
const layoutClasses = $derived(limitToSingleRow ? 'flex-shrink-0' : 'min-w-0');
function toMcpResourceAttachment(
extra: DatabaseMessageExtraMcpResource,
id: string
): MCPResourceAttachment {
return {
id,
resource: {
name: extra.name,
serverName: extra.serverName,
title: extra.name,
uri: extra.uri
}
};
}
</script>
{#if isMcpPrompt(item)}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
arguments: item.uploadedFile.mcpPrompt.arguments,
content: item.textContent ?? '',
name: item.name,
promptName: item.uploadedFile.mcpPrompt.promptName,
serverName: item.uploadedFile.mcpPrompt.serverName,
type: AttachmentType.MCP_PROMPT as const
}
: null}
{#if mcpPrompt}
<ChatAttachmentsListItemMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
prompt={mcpPrompt}
{readonly}
/>
{/if}
{:else if isMcpResource(item)}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentsListItemMcpResource
attachment={toMcpResourceAttachment(mcpResource, item.id)}
class="flex-shrink-0 {className} {scrollClasses}"
onclick={() => onMcpResourcePreview?.(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentsListItemThumbnailImage
class="{layoutClasses} cursor-pointer {className} {scrollClasses}"
height={imageHeight}
id={item.id}
{imageClass}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
preview={item.preview}
{readonly}
width={imageWidth}
/>
{:else if isPdfFile(item.attachment, item.uploadedFile)}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { ActionIcon, ChatMessageMcpPromptContent } from '$lib/components/app';
import { McpPromptVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
isLoading?: boolean;
loadError?: string;
onRemove?: () => void;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
}
let {
class: className = '',
isLoading = false,
loadError,
onRemove,
prompt,
readonly = false
}: Props = $props();
</script>
<div class="group relative {className}">
<ChatMessageMcpPromptContent
{isLoading}
{loadError}
{prompt}
variant={McpPromptVariant.ATTACHMENT}
/>
{#if !readonly && onRemove}
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIcon icon={X} onclick={() => onRemove?.()} stopPropagationOnClick tooltip="Remove" />
</div>
{/if}
</div>
@@ -0,0 +1,90 @@
<script lang="ts">
import { AlertCircle, Loader2 } from '@lucide/svelte';
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { mcpStore } from '$lib/stores';
import type { MCPResourceAttachment } from '$lib/types';
import { getResourceDisplayName, getResourceIcon } from '$lib/utils';
interface Props {
attachment: MCPResourceAttachment;
class?: string;
onclick?: () => void;
onRemove?: (attachmentId: string) => void;
}
let { attachment, class: className, onclick, onRemove }: Props = $props();
const ResourceIcon = $derived(
getResourceIcon(attachment.resource.mimeType, attachment.resource.uri)
);
const serverName = $derived(mcpStore.getServerDisplayName(attachment.resource.serverName));
const favicon = $derived(mcpStore.getServerFavicon(attachment.resource.serverName));
function getStatusClass(attachment: MCPResourceAttachment): string {
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
if (attachment.loading) return 'border-border/50 bg-muted/30';
return 'border-border/50 bg-muted/30';
}
</script>
<Tooltip.Root>
<Tooltip.Trigger>
<button
class={[
'flex flex-shrink-0 items-center gap-1.5 rounded-md border px-2 py-0.75 text-sm transition-colors',
getStatusClass(attachment),
onclick && 'cursor-pointer hover:bg-muted/50',
className
]}
disabled={!onclick}
{onclick}
type="button"
>
{#if attachment.loading}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{:else if attachment.error}
<AlertCircle class="h-3 w-3 text-red-500" />
{:else}
<ResourceIcon class="h-3 w-3 text-muted-foreground" />
{/if}
<span class="max-w-[150px] truncate text-xs">
{getResourceDisplayName(attachment.resource)}
</span>
{#if onRemove}
<ActionIcon
class="-my-2 -mr-1.5 bg-transparent"
icon={X}
iconSize="h-2 w-2"
onclick={() => onRemove?.(attachment.id)}
stopPropagationOnClick
tooltip="Remove"
/>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<div class="flex items-center gap-1 text-xs">
{#if favicon}
<img
alt={attachment.resource.serverName}
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">
{serverName}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,187 @@
<script lang="ts">
import { Music, Video, X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { AttachmentType } from '$lib/enums';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
isAudioFile,
isPdfFile,
isTextFile,
isVideoFile
} from '$lib/utils';
interface Props {
attachment?: DatabaseMessageExtra;
class?: string;
id: string;
onclick?: (event: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
readonly?: boolean;
size?: number;
textContent?: string;
// Either uploaded file or stored attachment
uploadedFile?: ChatUploadedFile;
}
let {
attachment,
class: className = '',
id,
name,
onclick,
onRemove,
readonly = false,
size,
textContent,
uploadedFile
}: Props = $props();
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isAudio = $derived(isAudioFile(attachment, uploadedFile));
let isVideo = $derived(isVideoFile(attachment, uploadedFile));
let isPdfWithContent = $derived(isPdf && !!textContent);
let isText = $derived(isTextFile(attachment, uploadedFile));
let isTextWithContent = $derived(isText && !!textContent);
let fileTypeLabel = $derived.by(() => {
if (uploadedFile?.type) {
return getFileTypeLabel(uploadedFile.type);
}
if (attachment) {
if ('mimeType' in attachment && attachment.mimeType) {
return getFileTypeLabel(attachment.mimeType);
}
if (attachment.type) {
return getFileTypeLabel(attachment.type);
}
}
return getFileTypeLabel(name);
});
let pdfProcessingMode = $derived.by(() => {
if (attachment?.type === AttachmentType.PDF) {
const pdfAttachment = attachment as DatabaseMessageExtraPdfFile;
return pdfAttachment.processedAsImages ? 'Sent as Image' : 'Sent as Text';
}
return null;
});
</script>
{#snippet textPreview(content: string)}
<div class="relative">
<div
class="font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground {!readonly
? 'max-h-3rem line-height-1.2'
: ''}"
>
{getPreviewText(content)}
</div>
{#if content.length > 150}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-4 bg-gradient-to-t from-muted to-transparent {readonly
? 'h-6'
: ''}"
></div>
{/if}
</div>
{/snippet}
{#snippet removeButton()}
<div
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon icon={X} onclick={() => onRemove?.(id)} stopPropagationOnClick tooltip="Remove" />
</div>
{/snippet}
{#snippet fileIcon()}
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{#if isAudio}
<Music class="{ICON_CLASS_DEFAULT} text-white/70" />
{:else if isVideo}
<Video class="{ICON_CLASS_DEFAULT} text-white/70" />
{:else}
{fileTypeLabel}
{/if}
</div>
{/snippet}
{#snippet info(text: string | undefined)}
{#if text}
<span class="text-xs text-muted-foreground">{text}</span>
{/if}
{/snippet}
{#if isTextWithContent || isPdfWithContent}
<button
aria-label={readonly ? `Preview ${name}` : undefined}
class="rounded-lg border border-border bg-muted p-3 {className} cursor-pointer {readonly
? 'w-full max-w-2xl transition-shadow hover:shadow-md'
: `group relative text-left ${textContent ? 'max-h-24 max-w-72' : 'max-w-36'}`} overflow-hidden"
{onclick}
type="button"
>
{#if !readonly}
{@render removeButton()}
{/if}
<div class={[!readonly && 'pr-8', 'overflow-hidden']}>
{#if readonly}
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col items-start text-left">
<span class="w-full truncate text-sm font-medium text-foreground">{name}</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
{#if textContent}
{@render textPreview(textContent)}
{/if}
</div>
</div>
{:else}
<span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
{#if textContent}
{@render textPreview(textContent)}
{/if}
{/if}
</div>
</button>
{:else}
<button
class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
{onclick}
type="button"
>
{@render fileIcon()}
<div class="flex flex-col items-start gap-0.5">
<span
class="max-w-24 truncate text-sm font-medium text-foreground {readonly
? ''
: 'group-hover:pr-6'} md:max-w-32"
>
{name}
</span>
{@render info(pdfProcessingMode || (size ? formatFileSize(size) : undefined))}
</div>
{#if !readonly}
{@render removeButton()}
{/if}
</button>
{/if}
@@ -0,0 +1,65 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
interface Props {
class?: string;
height?: string;
id: string;
imageClass?: string;
onclick?: (event?: MouseEvent) => void;
onRemove?: (id: string) => void;
name: string;
preview: string;
readonly?: boolean;
width?: string;
}
let {
class: className = '',
height = 'h-16',
id,
imageClass = '',
name,
onclick,
onRemove,
preview,
readonly = false,
width = 'w-auto'
}: Props = $props();
</script>
{#snippet image()}
<img alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" src={preview} />
{/snippet}
<div
class="group relative min-w-0 overflow-hidden rounded-lg bg-muted shadow-lg dark:border dark:border-muted {className}"
>
{#if onclick}
<button
aria-label="Preview {name}"
class="block h-full w-full rounded-lg focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-none"
{onclick}
type="button"
>
{@render image()}
</button>
{:else}
{@render image()}
{/if}
{#if !readonly}
<div
class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon
class="text-white"
icon={X}
onclick={() => onRemove?.(id)}
stopPropagationOnClick
tooltip="Remove"
/>
</div>
{/if}
</div>
@@ -0,0 +1,214 @@
<script lang="ts">
import {
ChatAttachmentsPreviewCurrentItem,
ChatAttachmentsPreviewFileInfo,
ChatAttachmentsPreviewNavButtons,
ChatAttachmentsPreviewThumbnailStrip
} from '$lib/components/app';
import { UI_DATA_ATTRS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import {
createBase64DataUrl,
formatFileSize,
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
isImageFile,
isMcpPrompt,
isMcpResource,
isPdfFile,
isTextFile,
isVideoFile
} from '$lib/utils';
interface PreviewItem {
id: string;
name: string;
size?: number;
preview?: string;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
textContent?: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
}
interface Props {
uploadedFiles?: ChatUploadedFile[];
attachments?: DatabaseMessageExtra[];
activeModelId?: string;
class?: string;
previewFocusIndex?: number;
}
let {
activeModelId,
attachments = [],
class: className = '',
previewFocusIndex = 0,
uploadedFiles = []
}: Props = $props();
let allItems = $derived(
getAttachmentDisplayItems({ attachments, uploadedFiles })
.filter((item) => !isMcpPrompt(item) && !isMcpResource(item))
.map(
(item): PreviewItem => ({
...item,
isAudio: isAudioFile(item.attachment, item.uploadedFile),
isImage: isImageFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
);
let currentIndex = $state(0);
$effect(() => {
if (previewFocusIndex >= 0 && previewFocusIndex < allItems.length) {
currentIndex = previewFocusIndex;
}
});
$effect(() => {
const handler = (e: Event) => {
const delta = (e as CustomEvent).detail;
if (delta < 0) {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
} else {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
};
document.addEventListener('chat-attachments-nav', handler);
return () => document.removeEventListener('chat-attachments-nav', handler);
});
$effect(() => {
const index = currentIndex;
setTimeout(() => {
const thumbnail = document.querySelector(`[${UI_DATA_ATTRS.THUMBNAIL_INDEX}="${index}"]`);
thumbnail?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, 0);
});
let currentItem = $derived(allItems[currentIndex] ?? null);
let displayName = $derived(
currentItem?.name ||
currentItem?.uploadedFile?.name ||
currentItem?.attachment?.name ||
'Unknown File'
);
let isAudio = $derived(
currentItem ? isAudioFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isVideo = $derived(
currentItem ? isVideoFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isImage = $derived(
currentItem ? isImageFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isPdf = $derived(
currentItem ? isPdfFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isText = $derived(
currentItem ? isTextFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let displayPreview = $derived(
currentItem?.uploadedFile?.preview ||
(isImage && currentItem?.attachment && 'base64Url' in currentItem.attachment
? currentItem.attachment.base64Url
: currentItem?.preview)
);
let displayTextContent = $derived(
currentItem?.uploadedFile?.textContent ||
(currentItem?.attachment && 'content' in currentItem.attachment
? currentItem.attachment.content
: currentItem?.textContent)
);
let language = $derived(getLanguageFromFilename(displayName));
let fileSize = $derived(currentItem?.size ? formatFileSize(currentItem.size) : '');
let hasVisionModality = $derived(
currentItem && activeModelId ? modelsStore.props.modelSupportsVision(activeModelId) : false
);
let audioSrc = $derived(
isAudio && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
let videoSrc = $derived(
isVideo && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
export function prev() {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
}
export function next() {
currentIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
}
function onNavigate(index: number) {
currentIndex = index;
}
</script>
<div class="{className} flex flex-col text-white">
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<ChatAttachmentsPreviewNavButtons onNext={next} onPrev={prev} show={allItems.length > 1} />
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
{#if currentItem}
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
<ChatAttachmentsPreviewCurrentItem
{activeModelId}
{audioSrc}
{currentItem}
{displayPreview}
{displayTextContent}
{hasVisionModality}
{isAudio}
{isImage}
{isPdf}
{isText}
{isVideo}
{language}
{videoSrc}
/>
{/if}
<ChatAttachmentsPreviewThumbnailStrip {currentIndex} items={allItems} {onNavigate} />
</div>
</div>
</div>
@@ -0,0 +1,74 @@
<script lang="ts">
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import { FileIcon, FileText, Image, Music, Video } from '@lucide/svelte';
import type { ChatAttachmentDisplayItem } from '$lib/types';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
isPdf: boolean;
isText: boolean;
displayPreview: string | undefined;
displayTextContent: string | undefined;
audioSrc: string | null;
videoSrc: string | null;
language: string;
hasVisionModality: boolean;
activeModelId?: string;
}
let {
activeModelId,
audioSrc,
currentItem,
displayPreview,
displayTextContent,
hasVisionModality,
isAudio,
isImage,
isPdf,
isText,
isVideo,
language,
videoSrc
}: Props = $props();
let IconComponent = $derived(
isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : isVideo ? Video : FileIcon
);
let isUnavailable = $derived(
!isPdf && !isImage && !(isText && displayTextContent) && !isAudio && !isVideo
);
</script>
{#if currentItem}
{#key currentItem.id}
{#if isPdf}
<ChatAttachmentsPreviewCurrentItemPdf
{activeModelId}
{currentItem}
displayName={currentItem.name}
{displayTextContent}
{hasVisionModality}
/>
{:else if isImage}
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
{:else if isText && displayTextContent}
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {audioSrc} {currentItem} />
{:else if isVideo}
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
{:else if isUnavailable}
<ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
{/if}
{/key}
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import { Music } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
audioSrc: string | null;
}
let { audioSrc, currentItem }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if audioSrc}
<audio class="mb-4 w-full" controls src={audioSrc}>
Your browser does not support the audio element.
</audio>
{:else}
<p class="mb-4 text-white/70">Audio preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Audio'}</p>
</div>
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
interface Props {
currentItem: { name?: string } | null;
displayPreview: string | undefined;
}
let { currentItem, displayPreview }: Props = $props();
</script>
{#if displayPreview}
<div class="flex flex-1 items-center justify-center">
<img
alt={currentItem?.name || 'preview'}
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
src={displayPreview}
/>
</div>
{/if}
@@ -0,0 +1,186 @@
<script lang="ts">
import { Eye, FileText, Info } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { PdfViewMode } from '$lib/enums';
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { getLanguageFromFilename } from '$lib/utils';
import { convertPDFToImage } from '$lib/utils/browser-only';
interface Props {
currentItem: ChatAttachmentDisplayItem | null;
displayName: string;
displayTextContent: string | undefined;
hasVisionModality: boolean;
activeModelId?: string;
}
let { activeModelId, currentItem, displayName, displayTextContent, hasVisionModality }: Props =
$props();
let pdfViewMode = $state<PdfViewMode>(PdfViewMode.PAGES);
let pdfImages = $state<string[]>([]);
let pdfImagesLoading = $state(false);
let pdfImagesError = $state<string | null>(null);
let language = $derived(getLanguageFromFilename(displayName));
async function loadPdfImages() {
if (pdfImages.length > 0 || pdfImagesLoading || !currentItem) return;
pdfImagesLoading = true;
pdfImagesError = null;
try {
let file: File | null = null;
if (currentItem.uploadedFile?.file) {
file = currentItem.uploadedFile.file;
} else if (currentItem.attachment) {
// Check if we have pre-processed images
if (
'images' in currentItem.attachment &&
currentItem.attachment.images &&
Array.isArray(currentItem.attachment.images) &&
currentItem.attachment.images.length > 0
) {
pdfImages = currentItem.attachment.images;
return;
}
// Convert base64 back to File for processing
if ('base64Data' in currentItem.attachment && currentItem.attachment.base64Data) {
const base64Data = currentItem.attachment.base64Data;
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
file = new File([byteArray], displayName, { type: 'application/pdf' });
}
}
if (file) {
pdfImages = await convertPDFToImage(file);
} else {
throw new Error('No PDF file available for conversion');
}
} catch (error) {
pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
} finally {
pdfImagesLoading = false;
}
}
$effect(() => {
if (pdfViewMode === PdfViewMode.PAGES) {
loadPdfImages();
}
});
</script>
<div class="mb-4 flex items-center justify-end gap-2">
<Button
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
size="sm"
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
>
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
Text
</Button>
<Button
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
size="sm"
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
>
{#if pdfImagesLoading}
<div
class="mr-1 {ICON_CLASS_DEFAULT} animate-spin rounded-full border-2 border-current border-t-transparent"
></div>
{:else}
<Eye class="mr-1 {ICON_CLASS_DEFAULT}" />
{/if}
Pages
</Button>
</div>
{#if !hasVisionModality && activeModelId && currentItem}
<Alert.Root class="mb-4 max-w-4xl">
<Info class={ICON_CLASS_DEFAULT} />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="mx-1 cursor-pointer underline"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
>
text
</span>
will be sent to the model.
</span>
</Alert.Description>
</Alert.Root>
{/if}
{#if pdfImagesLoading}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
></div>
<p class="text-white/70">Converting PDF to images...</p>
</div>
</div>
{:else if pdfImagesError}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="mb-4 text-white/70">Failed to load PDF images</p>
<p class="text-sm text-white/50">{pdfImagesError}</p>
</div>
</div>
{:else if pdfImages.length > 0}
{#each pdfImages as image, index (image)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
<div class="h-4"></div>
{/each}
{:else}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">No PDF pages available</p>
</div>
</div>
{/if}
{#if pdfViewMode === PdfViewMode.TEXT && displayTextContent}
<div class="px-4 pb-4">
<SyntaxHighlightedCode
class="max-w-4xl"
code={displayTextContent}
{language}
maxHeight="none"
/>
</div>
{/if}
@@ -0,0 +1,16 @@
<script lang="ts">
import { SyntaxHighlightedCode } from '$lib/components/app';
interface Props {
displayTextContent: string | undefined;
language: string;
}
let { displayTextContent, language }: Props = $props();
</script>
{#if displayTextContent}
<div class="px-4 pb-4 min-h-0 min-w-0 max-w-full md:max-w-[80vw]">
<SyntaxHighlightedCode code={displayTextContent} {language} maxHeight="80dvh" />
</div>
{/if}
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
IconComponent: Component;
}
let { IconComponent }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<IconComponent class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">Preview not available for this file type</p>
</div>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Video } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
videoSrc: string | null;
}
let { currentItem, videoSrc }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if videoSrc}
<video class="mb-4 w-full" controls src={videoSrc}>
<track kind="captions" src="" />
Your browser does not support the video element.
</video>
{:else}
<p class="mb-4 text-white/70">Video preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Video'}</p>
</div>
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
displayName: string;
fileSize: string;
}
let { displayName, fileSize }: Props = $props();
</script>
<div class="sticky top-0 z-[20] mb-4 rounded-lg bg-black/5 px-4 py-2 text-center backdrop-blur-md">
<p class="font-medium text-white">{displayName}</p>
{#if fileSize}
<p class="text-xs text-white/60">{fileSize}</p>
{/if}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
onPrev: () => void;
onNext: () => void;
show: boolean;
}
let { onNext, onPrev, show }: Props = $props();
</script>
{#if show}
<Button
aria-label="Previous"
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onPrev}
size="icon"
variant="secondary"
>
<ChevronLeft class="size-4" />
</Button>
<Button
aria-label="Next"
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onNext}
size="icon"
variant="secondary"
>
<ChevronRight class="size-4" />
</Button>
{/if}
@@ -0,0 +1,70 @@
<script lang="ts">
import { FileText, Music, Video } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { ScrollCarouselVariant } from '$lib/enums';
interface PreviewItem {
id: string;
name: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
preview?: string;
}
interface Props {
items: PreviewItem[];
currentIndex: number;
onNavigate: (index: number) => void;
}
let { currentIndex, items, onNavigate }: Props = $props();
function getFileExtension(name: string): string {
const parts = name.split('.');
if (parts.length > 1) {
return parts.pop()?.toUpperCase() ?? '';
}
return '';
}
</script>
{#if items.length > 1}
<div class="sticky bottom-0 z-10 mt-4 flex-shrink-0">
<ScrollCarousel class="max-w-full" variant={ScrollCarouselVariant.CENTER}>
{#each items as item, index (item.id)}
<button
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
aria-label={`Go to ${item.name}`}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
]}
onclick={() => onNavigate(index)}
>
{#if item.isImage && item.preview}
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
{:else}
<div
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="{ICON_CLASS_DEFAULT} text-white/70" />
{:else if item.isVideo}
<Video class="{ICON_CLASS_DEFAULT} text-white/70" />
{:else}
<FileText class="{ICON_CLASS_DEFAULT} text-white/70" />
{/if}
<span class="font-mono text-[9px] text-white/60">{getFileExtension(item.name)}</span>
</div>
{/if}
</button>
{/each}
</ScrollCarousel>
</div>
{/if}
@@ -0,0 +1,677 @@
<script lang="ts">
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormCurrentWorkingDirectory,
ChatFormInput,
ChatFormInputFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
DialogMcpResourcesBrowser
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
INITIAL_FILE_SIZE,
INPUT_CLASSES,
PROMPT_CONTENT_SEPARATOR,
SETTING_CONFIG_DEFAULT
} from '$lib/constants';
import {
ContentPartType,
FileExtensionText,
KeyboardKey,
MimeTypeText,
SpecialFileType,
ToolSource
} from '$lib/enums';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
chatStore,
conversationsStore,
mcpStore,
modelsStore,
serverStore,
settingsStore,
toolsStore,
uiStore
} from '$lib/stores';
import type {
FileMentionEntry,
GetPromptResult,
MCPPromptInfo,
MCPResourceInfo,
PromptMessage
} from '$lib/types';
import {
buildMentionInsertion,
containsCodeSpan,
containsFileMentionLink,
findCommandToken,
findMentionToken,
getConversationModel,
isIMEComposing,
isOffsetInCodeBlock,
parseClipboardContent,
uuid
} from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
interface Props {
// Data
attachments?: DatabaseMessageExtra[];
uploadedFiles?: ChatUploadedFile[];
value?: string;
// UI State
class?: string;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showAddButton?: boolean;
showModelSelector?: boolean;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
let {
attachments = [],
class: className = '',
disabled = false,
isLoading = false,
onAttachmentRemove,
onFilesAdd,
onStop,
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange,
placeholder = 'Type a message...',
showAddButton = true,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable('')
}: Props = $props();
// Component References
// Shared handle of the two input renderers (plain textarea + rich chat form input).
type ChatInputHandle = {
focus(): void;
resetHeight(): void;
getElement(): HTMLElement | undefined;
getCaretOffset(): number;
setCaretOffset(offset: number): void;
};
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormInputFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let inputRef: ChatInputHandle | undefined = $state(undefined);
// Render-mode gate: the plain textarea by default, the rich chat form input
// while the buffer carries a `file://` mention link or a complete code
// span (badges and code chips need a DOM the textarea cannot provide).
// Demotes back once neither remains.
let useRichInput = $state(false);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Invisible anchor at the form's top edge so the mention/WD popovers
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
let cwd = $derived(
conversationsStore.activeConversation?.cwd ?? conversationsStore.preferences.pendingCwd
);
const pickers = useChatFormPickers({
focusInput: refocusInput,
getCaretOffset: () => inputRef?.getCaretOffset(),
getCwd: () => cwd,
getPickersRef: () => pickersRef,
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
// policy-aware, same rule as the agentic flow: MCP category on and at
// least one globally-enabled server whose group key is not disabled
hasPrompts: () => {
const prefs = conversationsStore.preferences;
if (!prefs.isCategoryEnabled(ToolSource.MCP)) return false;
return mcpStore
.getServers()
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
},
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
value = v;
onValueChange?.(v);
}
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
await conversationsStore.preferences.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(newDir?.trim() || null);
}
}
// Resource Dialog State
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
// MCP Servers Dialog State
let currentConfig = $derived(settingsStore.config);
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let isRouter = $derived(serverStore.isRouterMode);
let conversationModel = $derived(
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
);
let activeModelId = $derived(modelsStore.activeModelId);
let hasModelSelected = $derived(
!isRouter || !!conversationModel || !!modelsStore.selectedModelId
);
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
// Caret offset restored after a renderer swap. Callers that mutate
// `value` themselves (e.g. the mention picker) pin the target offset
// BEFORE the assignment; otherwise the swap effect snapshots the
// current caret.
let pendingCaretOffset = 0;
let caretOffsetPinned = false;
function queueCaretRestore() {
queueMicrotask(() => {
inputRef?.focus();
inputRef?.setCaretOffset(pendingCaretOffset);
caretOffsetPinned = false;
});
}
$effect(() => {
const wantRichInput = containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useRichInput === wantRichInput) return;
if (!caretOffsetPinned) {
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
}
useRichInput = wantRichInput;
queueCaretRestore();
});
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
export function focus() {
inputRef?.focus();
}
export function resetTextareaHeight() {
inputRef?.resetHeight();
}
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handleKeydown(event: KeyboardEvent) {
// Pickers consume navigation/escape keys first; when consumed, skip
// the enter-to-submit logic below.
if (pickers.handleKeydown(event)) {
return;
}
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
// Caret inside a fenced code block (closed, or still open
// while being typed): Enter adds a line, never submits. The
// rich chat form input consumes this case locally; this gate
// covers the plain textarea, where skipping submit lets the
// native newline through.
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
return;
}
if (sendOnEnter || isModifier) {
event.preventDefault();
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith(CLIPBOARD_CONTENT_QUOTE_PREFIX)) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
// Handle text attachments as files
if (parsed.textAttachments.length > 0) {
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
}
// Handle MCP prompt attachments as ChatUploadedFile with mcpPrompt data
if (parsed.mcpPromptAttachments.length > 0) {
const mcpPromptFiles: ChatUploadedFile[] = parsed.mcpPromptAttachments.map((att) => ({
file: new File([att.content], `${att.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
id: uuid(),
isLoading: false,
mcpPrompt: {
arguments: att.arguments,
promptName: att.promptName,
serverName: att.serverName
},
name: att.name,
size: att.content.length,
textContent: att.content,
type: SpecialFileType.MCP_PROMPT
}));
uploadedFiles = [...uploadedFiles, ...mcpPromptFiles];
onUploadedFilesChange?.(uploadedFiles);
}
setTimeout(() => {
inputRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
pickers.closePromptPicker();
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
file: new File([], 'loading'),
id: placeholderId,
isLoading: true,
mcpPrompt: {
arguments: args ? { ...args } : undefined,
promptName: promptInfo.name,
serverName: promptInfo.serverName
},
name: promptName,
size: INITIAL_FILE_SIZE,
type: SpecialFileType.MCP_PROMPT
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
inputRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg: PromptMessage) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === ContentPartType.TEXT) {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join(PROMPT_CONTENT_SEPARATOR);
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
file: new File([promptText], `${f.name}${FileExtensionText.TXT}`, {
type: MimeTypeText.PLAIN
}),
isLoading: false,
size: promptText.length,
textContent: promptText
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
// Deferred so the closing popover's focus scope tears down first -
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => inputRef?.focus());
}
// A flow outside the chat, e.g. the models page returning to it, asks for the
// composer back. Focus on a frame of its own, once the route has settled.
$effect(() => {
if (!uiStore.composerFocusRequested) return;
uiStore.composerFocusRequested = false;
setTimeout(() => inputRef?.focus(), 0);
});
// Splice the mention link in place of the `@<query>` token. Uses the
// live cursor, not a stale snapshot - the token may have been edited.
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
// otherwise the effect clobbers it with the textarea's selection
// at promotion time (browser-dependent: usually reset to 0).
pendingCaretOffset = built.caretOffset;
caretOffsetPinned = true;
value = built.newValue;
onValueChange?.(built.newValue);
// Already in rich chat form input mode: no renderer flip, so the swap
// effect's caret restore never runs.
if (useRichInput) {
queueCaretRestore();
}
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
if (isRecording) {
isRecording = false;
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFilesAdd?.([audioFile]);
} catch (error) {
console.error('Failed to stop recording:', error);
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
</script>
<ChatFormInputFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
class="relative grid {className}"
onsubmit={(event) => {
event.preventDefault();
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}}
>
<ChatFormPickers
bind:this={pickersRef}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
isCommandPickerOpen={pickers.isCommandPickerOpen}
isMentionPickerOpen={pickers.isMentionPickerOpen}
isPromptPickerOpen={pickers.isPromptPickerOpen}
{mentionAnchor}
mentionQuery={pickers.mentionQuery}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
onMentionOpened={() => inputRef?.focus()}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionSelect={handleMentionSelect}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onPromptLoadStart={handlePromptLoadStart}
onPromptPickerClose={pickers.handlePromptPickerClose}
promptSearchQuery={pickers.promptSearchQuery}
scopePath={pickers.mentionScopePath}
/>
<div
bind:this={mentionAnchor}
aria-hidden="true"
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
></div>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
>
<ChatAttachmentsList
bind:uploadedFiles
activeModelId={activeModelId ?? undefined}
{attachments}
class="py-5"
limitToSingleRow
onFileRemove={handleFileRemove}
style="scroll-padding: 1rem;"
/>
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
>
<ChatFormInput
bind:this={inputRef}
bind:value
class="px-5 py-1.5 md:pt-0"
{disabled}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onKeydown={handleKeydown}
onPaste={handlePaste}
{placeholder}
{useRichInput}
/>
{#if mcpStore.resources.hasAttachments}
<ChatFormMcpResourcesList
class="mb-3"
onResourceClick={(uri) => {
preSelectedResourceUri = uri;
isResourceDialogOpen = true;
}}
/>
{/if}
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={canSubmit}
class="px-3"
{disabled}
{isLoading}
isReasoning={chatStore.isReasoning}
{isRecording}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
{showAddButton}
{showModelSelector}
{uploadedFiles}
/>
</div>
</div>
<ContextGaugePopup />
{#if conversationsStore.preferences.hasEnabledCwdTools()}
<ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
directory={cwd}
{disabled}
isOpen={pickers.isWorkingDirectoryPickerOpen}
onChange={handleWorkingDirectoryChange}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
/>
{/if}
</form>
<DialogMcpResourcesBrowser
bind:open={isResourceDialogOpen}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
onOpenChange={(newOpen: boolean) => {
if (!newOpen) {
preSelectedResourceUri = undefined;
}
}}
preSelectedUri={preSelectedResourceUri}
/>
@@ -0,0 +1,33 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_TOOLTIP_TEXT, ICON_CLASS_DEFAULT } from '$lib/constants';
interface Props {
disabled?: boolean;
onclick?: (e: MouseEvent) => void;
}
let { disabled = false, onclick }: Props = $props();
</script>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button bg-background h-9 w-9 rounded-full p-0 shadow-sm md:h-8 md:w-8 dark:bg-muted-foreground/15"
{disabled}
{onclick}
type="button"
variant="ghost"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class={ICON_CLASS_DEFAULT} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,206 @@
<script lang="ts">
import {
ChevronDown,
ChevronRight,
File,
Image,
MessageSquare,
Mic,
PencilRuler,
Video
} from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Drawer from '$lib/components/ui/drawer';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_MENU_TEXT,
ATTACHMENT_MODALITY_TEXT,
ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import type { ToolGroup } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
}
let { class: className = '', trigger }: Props = $props();
const chatFormActions = getChatFormActionsContext();
let drawerOpen = $state(false);
let toolsExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
drawerOpen = false;
}
);
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
icon: Mic,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
},
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
icon: Video,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
},
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
icon: Image,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
}
};
const supportedModalities = $derived.by(() =>
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
.filter((modality) => modality !== undefined)
);
const toolsPanel = useToolsPanel();
const itemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
const itemRowClass =
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
</script>
<div class="flex items-center gap-1 {className}">
<Drawer.Root bind:open={drawerOpen}>
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (drawerOpen = true) })}
<Drawer.Content class="gap-0 overflow-y-auto">
<Drawer.Header>
<Drawer.Title>{ATTACHMENT_MENU_TEXT.ADD_TO_CHAT}</Drawer.Title>
<Drawer.Description class="sr-only">
{ATTACHMENT_MENU_TEXT.DESCRIPTION}
</Drawer.Description>
</Drawer.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
<button
class={itemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
type="button"
>
<File class="{ICON_CLASS_DEFAULT} shrink-0" />
<span class="flex min-w-0 items-center gap-2">
<span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
{#if supportedModalities.length > 0}
<span class="flex items-center gap-0.75 text-muted-foreground">
{#each supportedModalities as modality (modality.label)}
<Tooltip.Root>
<Tooltip.Trigger>
<modality.icon class="size-2.75" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modality.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
</span>
</button>
<button
class={itemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
</button>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
<Collapsible.Trigger class={itemClass}>
{#if toolsExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
{:else}
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
<PencilRuler class="inline {ICON_CLASS_DEFAULT} shrink-0" />
<span class="flex-1">{ATTACHMENT_MENU_TEXT.TOOLS}</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.categoryGroups as group (group.key)}
{@render groupRow(group)}
{/each}
{#each toolsPanel.mcpGroups as group (group.key)}
{@render groupRow(group)}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
</div>
</Drawer.Content>
</Drawer.Root>
</div>
{#snippet groupRow(group: ToolGroup)}
{@const checkState = toolsPanel.getGroupCheckState(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<button
class="{itemRowClass} {groupDisabled ? 'pointer-events-none opacity-50' : ''}"
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{enabledCount}/{group.tools.length}
</span>
<Checkbox
checked={checkState.checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/snippet}
@@ -0,0 +1,149 @@
<script lang="ts">
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_MENU_TEXT,
ATTACHMENT_MODALITY_TEXT,
ATTACHMENT_TOOLTIP_TEXT,
ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
const chatFormActions = getChatFormActionsContext();
let dropdownOpen = $state(false);
// The system message action moves focus to the message editor, so the menu
// must not restore focus to the trigger on close
let suppressCloseAutoFocus = false;
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
dropdownOpen = false;
}
);
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: {
icon: Mic,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]
},
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: {
icon: Video,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]
},
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: {
icon: Image,
label: ATTACHMENT_MODALITY_TEXT[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]
}
};
const supportedModalities = $derived.by(() =>
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
.filter((modality) => modality !== undefined)
);
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
<!-- ignoreNonKeyboardFocus prevents the tooltip from flashing when the
menu closes and focus returns to the trigger -->
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Trigger
{...props}
class={cn(
// ghost brings no fill of its own for the muted composer surface to fight over
buttonVariants({ variant: 'ghost' }),
'file-upload-button h-8 w-8 cursor-pointer rounded-full bg-background p-0 shadow-sm dark:bg-muted-foreground/15'
)}
disabled={chatFormActions.disabled}
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class={ICON_CLASS_DEFAULT} />
</DropdownMenu.Trigger>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Content
align="start"
class="w-52"
onCloseAutoFocus={(e) => {
if (suppressCloseAutoFocus) {
suppressCloseAutoFocus = false;
e.preventDefault();
}
}}
>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
>
<File class={ICON_CLASS_DEFAULT} />
<span class="flex min-w-0 items-center gap-2">
<span>{ATTACHMENT_MENU_TEXT.ADD_FILES}</span>
{#if supportedModalities.length > 0}
<span class="flex items-center gap-0.75 text-muted-foreground">
{#each supportedModalities as modality (modality.label)}
<Tooltip.Root>
<Tooltip.Trigger>
<modality.icon class="size-2.75" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modality.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => {
suppressCloseAutoFocus = true;
chatFormActions.onSystemPromptClick?.();
}}
>
<MessageSquare class={ICON_CLASS_DEFAULT} />
<span>{ATTACHMENT_MENU_TEXT.SYSTEM_MESSAGE}</span>
</DropdownMenu.Item>
<ChatFormActionAddToolsSubmenu />
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,170 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Info, Loader2, PencilRuler } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ATTACHMENT_MENU_TEXT, CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
</script>
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<PencilRuler class={ICON_CLASS_DEFAULT} />
<span>{ATTACHMENT_MENU_TEXT.TOOLS}</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 p-0">
{#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 {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 {ICON_CLASS_DEFAULT} shrink-0" />
<span>
Run llama-server with <code>{CLI_FLAGS.TOOLS}</code> flag to enable
<strong>Server Tools</strong>.
</span>
</span>
<span class="flex gap-2">
<Info class="mt-0.5 {ICON_CLASS_DEFAULT} shrink-0" />
<span>
{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
<strong>MCP Tools</strong>.
</span>
</span>
</div>
{:else if toolsStore.error}
<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 {ICON_CLASS_DEFAULT} shrink-0" />
<span>{toolsPanel.noToolsInfoMessage}</span>
</div>
{:else}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">No tools available</div>
{/if}
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.categoryGroups as group (group.key)}
{@render groupRow(group)}
{/each}
{#each toolsPanel.mcpGroups as group (group.key)}
{@render groupRow(group)}
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#snippet groupRow(group: ToolGroup)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
{@const checkState = toolsPanel.getGroupCheckState(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<Collapsible.Root
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1 {groupDisabled ? 'pointer-events-none opacity-50' : ''}">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
checked={checkState.checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checkState.checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsPanel.isToolEnabled(entry)}
{@const parentDisabled = toolsPanel.isToolParentDisabled(entry)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50 {parentDisabled
? 'opacity-50'
: ''}"
onclick={() => toolsPanel.toggleTool(entry)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/snippet}
@@ -0,0 +1,16 @@
<script lang="ts">
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDrawer from './ChatFormActionAddDrawer.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import { deviceStore } from '$lib/stores';
</script>
{#if deviceStore.isMobile}
<ChatFormActionAddDrawer>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
{/snippet}
</ChatFormActionAddDrawer>
{:else}
<ChatFormActionAddDropdown />
{/if}
@@ -0,0 +1,147 @@
<script lang="ts">
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Drawer from '$lib/components/ui/drawer';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { deviceStore } from '$lib/stores';
import type { ReasoningEffortLevel } from '$lib/types';
const reasoning = useReasoningMenu();
let isOpen = $state(false);
// "Default" is the resting state, where the bulb alone carries the meaning
let isDefault = $derived(reasoning.currentEffort === ReasoningEffort.DEFAULT);
// a phone picks the level in a drawer, where a thumb reaches it
let isMobile = $derived(deviceStore.isMobile);
const MAX_EFFORT_HINT = 'Maximum reasoning effort with extended context usage';
function select(level: ReasoningEffortLevel): void {
reasoning.select(level);
isOpen = false;
}
</script>
{#snippet triggerLabel()}
<span class="flex items-center gap-0.75 {reasoning.isOff ? 'text-muted-foreground' : ''}">
{#if reasoning.isOff}
<LightbulbOff class="size-3 max-md:size-4 shrink-0" />
{:else}
<Lightbulb class="size-3 max-md:size-4 shrink-0" />
{/if}
{#if !isDefault}
<span class="capitalize">{reasoning.currentEffort}</span>
{/if}
</span>
{/snippet}
{#snippet levelRow(level: ReasoningEffortLevel)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<button
aria-pressed={reasoning.isSelected(level)}
class="flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-sm transition-colors hover:bg-accent"
onclick={() => select(level)}
type="button"
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
<span class="min-w-0 flex-1 truncate">{level.label}</span>
{#if tokenLabel}
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</span>
{/if}
{#if level.hasInfo}
<!-- no hover on a phone: the hint rides on the icon's own title there -->
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" title={MAX_EFFORT_HINT} />
{/if}
</button>
{/snippet}
<!-- the level belongs to the chat, not to the model list -->
{#if isMobile}
<Button
aria-label="Reasoning effort"
class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
? 'px-1'
: 'px-1.75!'} py-1 text-xs max-md:h-8"
onclick={() => (isOpen = true)}
variant="ghost"
>
{@render triggerLabel()}
</Button>
<Drawer.Root bind:open={isOpen}>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Reasoning effort</Drawer.Title>
</Drawer.Header>
<div class="flex flex-col px-2 pb-4">
{#each reasoning.levels as level (level.value)}
{@render levelRow(level)}
{/each}
</div>
</Drawer.Content>
</Drawer.Root>
{:else}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="Reasoning effort"
class="h-auto gap-1 rounded-sm bg-transparent! backdrop-blur-none! {isDefault
? 'px-1'
: 'px-1.75!'} py-1 text-xs hover:bg-transparent!"
variant="ghost"
>
{@render triggerLabel()}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="min-w-56">
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item class="gap-3" onSelect={() => reasoning.select(level)}>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
<span class="min-w-0 flex-1 truncate">{level.label}</span>
{#if tokenLabel}
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">{tokenLabel}</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{MAX_EFFORT_HINT}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -0,0 +1,53 @@
<script lang="ts">
import { Mic, Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
isLoading?: boolean;
isRecording?: boolean;
onMicClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
isLoading = false,
isRecording = false,
onMicClick
}: Props = $props();
</script>
<div class="flex items-center gap-1 {className}">
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="h-8 w-8 max-md:h-9 max-md:w-9 rounded-full p-0 {isRecording
? 'animate-pulse bg-red-500 text-white hover:bg-red-600'
: ''}"
disabled={disabled || isLoading || !hasAudioModality}
onclick={onMicClick}
type="button"
>
<span class="sr-only">{isRecording ? 'Stop recording' : 'Start recording'}</span>
{#if isRecording}
<Square class="{ICON_CLASS_DEFAULT} animate-pulse fill-white" />
{:else}
<Mic class={ICON_CLASS_DEFAULT} />
{/if}
</Button>
</Tooltip.Trigger>
{#if !hasAudioModality}
<Tooltip.Content>
<p>Current model does not support audio</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { ArrowUp } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
canSend?: boolean;
disabled?: boolean;
showErrorState?: boolean;
tooltipLabel?: string;
}
let { canSend = false, disabled = false, showErrorState = false, tooltipLabel }: Props = $props();
let isDisabled = $derived(!canSend || disabled);
</script>
{#snippet submitButton(props = {})}
<Button
class={[
'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
disabled={isDisabled}
type="submit"
{...props}
>
<span class="sr-only">Send</span>
<ArrowUp class="h-12 w-12" />
</Button>
{/snippet}
{#if tooltipLabel}
<Tooltip.Root>
<Tooltip.Trigger>
{@render submitButton()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render submitButton()}
{/if}
@@ -0,0 +1,219 @@
<script lang="ts">
import { SkipForward, Square } from '@lucide/svelte';
import { page } from '$app/state';
import {
ChatFormActionReasoning,
ChatFormActionRecord,
ChatFormActionsAdd,
ChatFormActionSubmit,
ChatFormContextGauge,
ModelsSelector
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
import { ChatService } from '$lib/services';
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
import { getFileTypeCategory } from '$lib/utils';
interface Props {
canSend?: boolean;
canSubmit?: boolean;
class?: string;
disabled?: boolean;
isLoading?: boolean;
isReasoning?: boolean;
isRecording?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
uploadedFiles?: ChatUploadedFile[];
onFileUpload?: () => void;
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
}
let {
canSend = false,
canSubmit = false,
class: className = '',
disabled = false,
isLoading = false,
isReasoning = false,
isRecording = false,
onFileUpload,
onMicClick,
onStop,
onSystemPromptClick,
showAddButton = true,
showModelSelector = true,
uploadedFiles = []
}: Props = $props();
let currentConfig = $derived(settingsStore.config);
const formModel = useChatFormModel();
let hasAudioModality = $derived(formModel.hasAudioModality);
let hasVideoModality = $derived(formModel.hasVideoModality);
let hasVisionModality = $derived(formModel.hasVisionModality);
let hasModelSelected = $derived(formModel.hasModelSelected);
let isSelectedModelInCache = $derived(formModel.isSelectedModelInCache);
let submitTooltip = $derived.by(() => {
if (!hasModelSelected) return 'Please select a model first';
if (!isSelectedModelInCache) return 'Selected model is not available, please select another';
return '';
});
let hasAudioAttachments = $derived(
uploadedFiles.some((file) => getFileTypeCategory(file.type) === FileTypeCategory.AUDIO)
);
let shouldShowRecordButton = $derived(
hasAudioModality && !canSubmit && !hasAudioAttachments && currentConfig.autoMicOnEmpty
);
let selectorModelRef: ModelsSelector | undefined = $state(undefined);
export function openModelSelector() {
selectorModelRef?.open();
}
// the streaming assistant message carries both the completion id and the model that
// produced it, targeting reasoning control from the same source keeps them consistent
let activeMessage = $derived(
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]
);
let hasProcessedTokens = $derived.by(() => {
if (!page.params.id) return false;
const messages = conversationsStore.activeMessages as DatabaseMessage[];
let totalHistoricalTokens = 0;
for (const m of messages) {
if (m.role !== MessageRole.ASSISTANT) continue;
const timings = m.timings;
if (!timings) continue;
const agenticLlm = timings.agentic?.llm;
if (agenticLlm?.prompt_n != null || agenticLlm?.predicted_n != null) {
totalHistoricalTokens += (agenticLlm?.prompt_n ?? 0) + (agenticLlm?.predicted_n ?? 0);
} else {
totalHistoricalTokens += (timings.prompt_n ?? 0) + (timings.predicted_n ?? 0);
}
}
if (totalHistoricalTokens > 0) return true;
if (!chatStore.isLoading && !chatStore.isStreaming()) return false;
const processingState = chatStore.processing.activeState;
if (!processingState) return false;
const livePromptTokens = Math.max(
processingState.promptTokens ?? 0,
processingState.promptProgress?.processed ?? 0
);
const liveOutputTokens = processingState.outputTokensUsed ?? 0;
return livePromptTokens > 0 || liveOutputTokens > 0;
});
setChatFormActionsContext({
get disabled() {
return disabled;
},
get hasAudioModality() {
return hasAudioModality;
},
get hasVideoModality() {
return hasVideoModality;
},
get hasVisionModality() {
return hasVisionModality;
},
get onFileUpload() {
return onFileUpload;
},
get onSystemPromptClick() {
return onSystemPromptClick;
}
});
</script>
<div
class="flex w-full items-center gap-3 {className} {showAddButton ? '' : 'justify-end'}"
style="container-type: inline-size"
>
{#if showAddButton}
<div class="mr-auto flex items-center gap-2">
<ChatFormActionsAdd />
</div>
{/if}
<div class="flex items-center gap-1.5">
{#if hasProcessedTokens}
<ChatFormContextGauge />
{/if}
{#if showModelSelector}
<ChatFormActionReasoning />
<ModelsSelector
bind:this={selectorModelRef}
{disabled}
forceForegroundText
useGlobalSelection
/>
{/if}
</div>
{#if isReasoning}
<Button
class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
title="Skip reasoning"
type="button"
variant="secondary"
>
<span class="sr-only">Skip reasoning</span>
<SkipForward
class="{ICON_CLASS_DEFAULT} stroke-muted-foreground group-hover:stroke-foreground"
/>
</Button>
{/if}
{#if isLoading && !canSubmit}
<Button
class="group h-8 w-8 rounded-full p-0 max-md:h-9 max-md:w-9 hover:bg-destructive/10!"
onclick={onStop}
type="button"
variant="secondary"
>
<span class="sr-only">Stop</span>
<Square
class="h-8 w-8 fill-muted-foreground stroke-muted-foreground group-hover:fill-destructive group-hover:stroke-destructive hover:fill-destructive hover:stroke-destructive"
/>
</Button>
{:else if shouldShowRecordButton}
<ChatFormActionRecord {disabled} {hasAudioModality} {isLoading} {isRecording} {onMicClick} />
{:else}
<ChatFormActionSubmit
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
{disabled}
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
tooltipLabel={submitTooltip}
/>
{/if}
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte';
import {
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import { untrack } from 'svelte';
const gauge = useContextGauge();
$effect(() => {
const conv = conversationsStore.activeConversation;
untrack(() => chatStore.processing.setActiveConversation(conv?.id ?? null));
});
$effect(() => {
const conv = conversationsStore.activeConversation;
const messages = conversationsStore.activeMessages as DatabaseMessage[];
if (!conv) return;
if (chatStore.isLoading || chatStore.isStreaming()) return;
if (messages.length === 0) {
untrack(() => chatStore.processing.setState(conv.id, null));
return;
}
untrack(() => chatStore.processing.restoreFromMessages(messages, conv.id));
});
$effect(() => {
gauge.startMonitoring();
});
</script>
<div
aria-label="Context usage"
class="flex h-5 w-5 cursor-default items-center justify-center max-md:h-6 max-md:w-6"
data-context-gauge-trigger
onclick={gaugeTriggerClick}
onkeydown={gaugeTriggerKeydown}
onpointerdown={gaugeTriggerPointerDown}
onpointerenter={gaugeTriggerEnter}
onpointerleave={gaugeTriggerLeave}
role="button"
tabindex="0"
>
<!-- a phone gets the larger dial: the trigger is a touch target there -->
<ContextGaugeDial
level={gauge.colorLevel}
percent={gauge.contextPercent}
size={deviceStore.isMobile ? 'md' : 'sm'}
/>
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
label: string;
value: string;
subtitle?: string;
}
let { label, subtitle, value }: Props = $props();
</script>
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between">
<span class="text-muted-foreground">{label}</span>
<span class="font-mono text-muted-foreground">{value}</span>
</div>
{#if subtitle}
<div class="text-[10px] leading-tight text-muted-foreground/70 max-md:text-[11px]">
{subtitle}
</div>
{/if}
</div>
@@ -0,0 +1,118 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { STATS_UNITS } from '$lib/constants';
interface Props {
currentRead: number;
currentFresh: number;
currentCache: number;
currentOutput: number;
kvTotal: number;
cumulativeRead: number;
cumulativeOutput: number;
cumulativeCacheTotal: number;
averageTokensPerSecond: number | null;
transientDetails: string[];
}
let {
averageTokensPerSecond,
cumulativeCacheTotal,
cumulativeOutput,
cumulativeRead,
currentCache,
currentFresh,
currentOutput,
currentRead,
kvTotal,
transientDetails
}: Props = $props();
const hasCumulative = $derived(cumulativeRead > 0 || cumulativeOutput > 0);
const hasCurrent = $derived(currentRead > 0 || currentOutput > 0);
</script>
<div class="mt-3 border-t border-border/50 pt-4">
<p class="text-xs text-muted-foreground max-md:text-[13px]">Token usage details</p>
<div class="flex flex-col gap-4 pt-4 text-xs max-md:gap-5 max-md:text-[13px]">
{#if hasCumulative}
<div>
<h3
class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
>
Across all turns
</h3>
<div class="flex flex-col gap-2">
{#if cumulativeRead > 0}
<ContextGaugeDetailRow
label="Prompt tokens evaluated"
subtitle={cumulativeCacheTotal > 0
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
: undefined}
value={`${cumulativeRead.toLocaleString()} tok`}
/>
{/if}
{#if cumulativeOutput > 0}
<ContextGaugeDetailRow
label="Tokens generated"
value={`${cumulativeOutput.toLocaleString()} tok`}
/>
{/if}
</div>
</div>
{/if}
{#if hasCurrent}
<div>
<h3
class="text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70 mb-2 max-md:text-xs"
>
This turn · KV cache
</h3>
<div class="flex flex-col gap-2">
{#if currentRead > 0}
<ContextGaugeDetailRow
label="Prompt"
subtitle={currentCache > 0
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
: undefined}
value={`${currentRead.toLocaleString()} tok`}
/>
{/if}
{#if currentOutput > 0}
<ContextGaugeDetailRow
label="Generated"
value={`${currentOutput.toLocaleString()} tok`}
/>
{/if}
<div class="pt-1 mt-0.5 border-t border-border/30">
<div class="flex justify-between">
<span class="text-muted-foreground">KV cache total</span>
<span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
</div>
</div>
</div>
</div>
{/if}
{#if averageTokensPerSecond !== null}
<div class="pt-1.5 mt-1 border-t border-border/30">
<ContextGaugeDetailRow
label="Avg speed"
value={`${averageTokensPerSecond.toFixed(1)}${STATS_UNITS.TOKENS_PER_SECOND}`}
/>
</div>
{/if}
{#each transientDetails as detail (detail)}
<div class="font-mono text-muted-foreground">{detail}</div>
{/each}
</div>
</div>
@@ -0,0 +1,44 @@
<script lang="ts">
import { colorLevelTextClass } from './context-gauge';
import type { ColorLevel } from '$lib/enums';
interface Props {
percent: number | null;
level: ColorLevel;
size?: 'sm' | 'md';
}
let { level, percent, size = 'sm' }: Props = $props();
// the md dial fills its box: the ring would sit inside a padding ring otherwise
const RADIUS = $derived(size === 'md' ? 13 : 11);
const CIRCUMFERENCE = $derived(2 * Math.PI * RADIUS);
const strokeLevelClass = $derived(colorLevelTextClass(level));
const dimensions = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
const strokeWidth = $derived(size === 'md' ? 4 : 3);
</script>
<svg class={dimensions} fill="none" viewBox="0 0 32 32">
<circle
cx="16"
cy="16"
r={RADIUS}
stroke="currentColor"
stroke-opacity="0.1"
stroke-width={strokeWidth}
/>
<circle
class="transition-colors duration-300 {strokeLevelClass}"
cx="16"
cy="16"
r={RADIUS}
stroke="currentColor"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
stroke-linecap="round"
stroke-width={strokeWidth}
transform="rotate(-90 16 16)"
/>
</svg>
@@ -0,0 +1,26 @@
<script lang="ts">
import { Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
modelId: string | null;
isLoading: boolean;
onLoad: () => void;
}
let { isLoading, modelId, onLoad }: Props = $props();
</script>
{#if modelId !== null && !isLoading}
<div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<span>Available context size is only visible once the model is loaded.</span>
<Button class="self-start" onclick={onLoad} size="sm" variant="secondary">Load model</Button>
</div>
{:else if isLoading}
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>Loading model...</span>
</div>
{/if}
@@ -0,0 +1,150 @@
<script lang="ts">
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import {
gaugeCardEnter,
gaugeCardLeave,
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
import * as Drawer from '$lib/components/ui/drawer';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { deviceStore } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
// The gauge hook wraps a processing state instance that only follows the
// live stream while its own monitoring flag is set, so the card instance
// starts monitoring like the dial does.
$effect(() => {
gauge.startMonitoring();
});
let cardEl = $state<HTMLElement | null>(null);
// Any press outside the card and outside the dial closes the card.
// Presses on the dial are excluded because the dial handles its own
// toggle; the listener only exists while the card is open. A phone shows
// the panel in a drawer instead, which brings its own dismissal.
$effect(() => {
if (!gaugePopup.open || deviceStore.isMobile) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (cardEl?.contains(target)) return;
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
gaugePopupClose();
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
});
const showProgressBar = $derived(
gauge.contextTotal !== null &&
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
</script>
{#snippet usage()}
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
</span>
{/snippet}
{#snippet body()}
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
isLoading={gauge.isActiveModelLoading}
modelId={gauge.activeModelId}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
gauge.colorLevel
)}"
style="width: {gauge.contextPercent}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground max-md:text-[13px]">
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters(gauge.contextAvailable ?? 0)} remaining
</span>
</div>
{:else}
<div class="text-xs text-muted-foreground">No context info available</div>
{/if}
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
averageTokensPerSecond={gauge.averageTokensPerSecond}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeRead={gauge.cumulativeRead}
currentCache={gauge.currentCache}
currentFresh={gauge.currentFresh}
currentOutput={gauge.currentOutput}
currentRead={gauge.currentRead}
kvTotal={gauge.kvTotal}
transientDetails={gauge.transientDetails}
/>
{/if}
{/snippet}
{#if deviceStore.isMobile}
<Drawer.Root bind:open={gaugePopup.open}>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Context</Drawer.Title>
<Drawer.Description class="sr-only">
Context window usage of the model the chat runs
</Drawer.Description>
</Drawer.Header>
<div class="flex flex-col gap-2 px-4 pb-4 max-md:gap-3.5">
{@render usage()}
{@render body()}
</div>
</Drawer.Content>
</Drawer.Root>
{:else if gaugePopup.open}
<div
bind:this={cardEl}
class="absolute z-50 w-64 -translate-x-1/2 rounded-xl border border-border bg-popover p-1.5 text-sm text-popover-foreground shadow-md dark:border-border/20"
onpointerenter={gaugeCardEnter}
onpointerleave={gaugeCardLeave}
role="status"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
>
<div class="flex flex-col gap-2 px-2 py-1.5">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
{@render usage()}
</div>
{@render body()}
</div>
</div>
{/if}
@@ -0,0 +1,40 @@
import { ColorLevel } from '$lib/enums';
const WARNING_THRESHOLD = 80;
const CRITICAL_THRESHOLD = 95;
export function colorLevelFromPercent(percent: number | null): ColorLevel {
if (percent === null) return ColorLevel.NEUTRAL;
if (percent >= CRITICAL_THRESHOLD) return ColorLevel.CRITICAL;
if (percent >= WARNING_THRESHOLD) return ColorLevel.WARNING;
return ColorLevel.OK;
}
export function colorLevelTextClass(level: ColorLevel): string {
switch (level) {
case ColorLevel.CRITICAL:
return 'text-red-400';
case ColorLevel.WARNING:
return 'text-amber-400';
case ColorLevel.OK:
return 'text-muted-foreground';
default:
return 'text-muted-foreground';
}
}
export function colorLevelBgClass(level: ColorLevel): string {
switch (level) {
case ColorLevel.CRITICAL:
return 'bg-red-500';
case ColorLevel.WARNING:
return 'bg-amber-500';
case ColorLevel.OK:
return 'bg-green-500';
default:
return 'bg-muted';
}
}
@@ -0,0 +1,99 @@
// Shared state for the context gauge popup. The dial and the card live in
// different DOM subtrees, so open state and placement are coordinated here.
// centerX and bottom place the card just above the dial: both are measured
// once at open time, relative to the closest form ancestor; the dial and
// the card share that positioning frame, so the values stay exact for the
// whole time the card is open.
// Mouse pointers open on hover with a short grace delay to travel from
// dial to card; touch pointers toggle on tap.
import {
CONTEXT_GAUGE_CARD_HALF_WIDTH_PX,
CONTEXT_GAUGE_CLOSE_GRACE_MS,
CONTEXT_GAUGE_DIAL_GAP_PX,
CONTEXT_GAUGE_EDGE_MARGIN_PX
} from '$lib/constants';
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let lastPointerType = '';
export const gaugePopup = $state({ bottom: 0, centerX: 0, open: false });
function openFrom(trigger: HTMLElement): void {
clearTimeout(closeTimer);
const frame = trigger.closest('form');
if (frame) {
const frameRect = frame.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
}
gaugePopup.open = true;
}
function toggleFrom(trigger: HTMLElement): void {
if (gaugePopup.open) {
clearTimeout(closeTimer);
gaugePopup.open = false;
} else {
openFrom(trigger);
}
}
export function gaugePopupClose(): void {
clearTimeout(closeTimer);
gaugePopup.open = false;
}
export function gaugeTriggerPointerDown(event: PointerEvent): void {
lastPointerType = event.pointerType;
}
export function gaugeTriggerClick(event: MouseEvent): void {
if (lastPointerType !== 'touch') return;
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
openFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
scheduleClose();
}
export function gaugeCardEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
clearTimeout(closeTimer);
}
export function gaugeCardLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
scheduleClose();
}
function scheduleClose(): void {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
gaugePopup.open = false;
}, CONTEXT_GAUGE_CLOSE_GRACE_MS);
}
@@ -0,0 +1,417 @@
<script lang="ts">
import ChatFormCurrentWorkingDirectoryChip from './ChatFormCurrentWorkingDirectoryChip.svelte';
import ChatFormCurrentWorkingDirectoryResultsList from './ChatFormCurrentWorkingDirectoryResultsList.svelte';
import { FolderOpen } from '@lucide/svelte';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey, ToolSource } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { conversationsStore, toolsStore } from '$lib/stores';
import type { GlobEntry } from '$lib/types';
import {
abbreviateHome,
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
runGlobSearchWithChildren
} from '$lib/utils';
// Microtask delay so the popover's focus scope tears down first.
const FOCUS_DELAY_MS = 0;
interface Props {
class?: string;
disabled?: boolean;
directory?: string | null;
/** Controlled open state; the host owns it so the chip click and the
* `/cwd` slash command open the picker through the same path. */
isOpen: boolean;
/** Two-way bound query, kept in sync with the text after `/cwd `. */
query: string;
/** Anchor at the form's top edge so the popover floats above the box. */
customAnchor?: HTMLElement | null;
onChange?: (directory: string | null) => void;
/** Lets the host refocus the chat input after the popover closes. */
onClose?: () => void;
/** Fired when the chip is clicked so the host can open the picker. */
onOpen?: () => void;
}
let {
class: className = '',
customAnchor = null,
directory = null,
disabled = false,
isOpen,
onChange,
onClose,
onOpen,
query = $bindable('')
}: Props = $props();
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
// exposes a "Browse" button only when available.
const pickerSupported =
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
// When the server does not serve file_glob_search or the user disabled
// it, the picker still opens for manual entry but explains why search is
// unavailable instead of firing searches that would only fail. Browse is
// hidden too: it resolves the picked folder name through the same tool.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
const searchUnavailableMessage = $derived(
fileSearchKey === null
? 'File search is unavailable on this server - type a full path and press Enter'
: 'File search is disabled - type a full path and press Enter, or enable "Search files" in Settings > Tools'
);
let searchInputRef: HTMLInputElement | null = $state(null);
let queryResults = $state<string[]>([]);
let searchError = $state<string | null>(null);
let listContainer = $state<HTMLDivElement | null>(null);
const nav = usePickerNavigation({
count: () => queryResults.length,
isOpen: () => isOpen,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
let homeBase = $derived(toolsStore.serverHome);
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
$effect(() => {
if (!isOpen) return;
const q = query.trim();
nav.reset(-1);
if (q && fileSearchEnabled) {
search.run(q);
} else {
search.cancel();
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
}
});
useScrollActiveRow({
dataAttr: UI_DATA_ATTRS.RESULT_INDEX,
getContainer: () => listContainer,
getCount: () => queryResults.length,
getIndex: () => nav.hoveredIndex,
getTrigger: () => nav.scrollTrigger
});
let searchScope = $state(HOME_TILDE);
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH.DEBOUNCE_MS,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
if (!trimmed) {
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
return;
}
try {
// Generous limit: ranking is client-side, only the top
// MAX_RESULTS_SHOWN are shown.
const res = await runGlobSearchWithChildren(
trimmed,
homeBase ?? HOME_TILDE,
SEARCH.MAX_DEPTH,
SEARCH.LIMIT,
signal,
{ type: GlobSearchType.DIR }
);
if (!isCurrent()) return;
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, SEARCH.MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
}
}
});
// Single funnel for every local close so the host refocus always fires.
function closePicker() {
onClose?.();
}
function commit(path: string) {
onChange?.(path);
closePicker();
}
function setDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
onChange?.(trimmed);
}
// Resolve a browser-picked folder name (which exposes only the leaf name)
// to a server-side absolute path; null when the server cannot locate it,
// so the caller fails visibly instead of committing a bare leaf name.
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
include: buildCaseInsensitiveGlob(name),
limit: SEARCH.NATIVE_LIMIT,
max_depth: SEARCH.NATIVE_MAX_DEPTH,
path: homeBase ?? HOME_TILDE,
type: GlobSearchType.DIR
});
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const match = entries.find(
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
);
return match ? joinPath(base, match.path) : null;
} catch {
return null;
}
}
async function browseNative() {
if (disabled || !window.showDirectoryPicker) return;
try {
const handle = await window.showDirectoryPicker();
const path = await resolveNativeName(handle.name);
if (path) {
setDirectory(path);
closePicker();
} else {
// keep the previous cwd and fail visibly instead of committing a
// bare leaf name that would resolve against the server cwd
searchError = `Could not resolve "${handle.name}" to a server path`;
}
} catch (err) {
// user cancelled - silently ignore; other errors are logged
if (err instanceof DOMException && err.name === 'AbortError') return;
console.error('[ChatFormCurrentWorkingDirectory] showDirectoryPicker failed:', err);
}
}
function handleSubmit() {
const value = query.trim();
if (!value) {
closePicker();
return;
}
setDirectory(value);
closePicker();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
handleSubmit();
}
} else if (event.key === KeyboardKey.ARROW_DOWN) {
if (queryResults.length > 0) {
event.preventDefault();
nav.move(1);
}
} else if (event.key === KeyboardKey.ARROW_UP) {
if (queryResults.length > 0) {
event.preventDefault();
nav.move(-1);
}
}
}
function clearDirectory(event?: MouseEvent) {
// Stop the click from bubbling into the chip button and re-opening
// the picker on top of the now-cleared state.
event?.stopPropagation();
event?.preventDefault();
onChange?.(null);
closePicker();
}
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (directory) {
clearDirectory(event);
}
}
function handleOpenChange(open: boolean) {
if (open) {
void toolsStore.resolveServerHome();
} else {
search.cancel();
// bits-ui-initiated close (Escape on the content, outside-click) -
// the only path that bypasses closePicker().
onClose?.();
}
}
let innerWidth = $state(0);
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
</script>
<button
class={[
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
className
]}
{disabled}
onclick={onOpen}
type="button"
>
<ChatFormCurrentWorkingDirectoryChip
{directory}
{disabled}
{homeBase}
onClear={handleDismiss}
{showTooltip}
/>
</button>
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
>
<span class="sr-only">Open working directory picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<div class="p-2 min-h-22 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={query}
class="w-full"
onClose={closePicker}
placeholder="Choose working directory"
/>
{#if !fileSearchEnabled}
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormCurrentWorkingDirectoryResultsList
bind:container={listContainer}
error={searchError}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
rawQuery={query}
results={queryResults}
/>
{/if}
{#if pickerSupported && fileSearchEnabled}
<button
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
type="button"
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase && fileSearchEnabled}
<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
<svelte:window bind:innerWidth />
@@ -0,0 +1,71 @@
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app/actions';
import * as Tooltip from '$lib/components/ui/tooltip';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import { abbreviateWorkingDir } from '$lib/utils';
interface Props {
directory?: string | null;
homeBase?: string | null;
disabled?: boolean;
showTooltip?: boolean;
onClear?: (event?: MouseEvent) => void;
}
let {
directory = null,
disabled = false,
homeBase = null,
onClear,
showTooltip = false
}: Props = $props();
const displayLabel = $derived(
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
);
// Full path surface: hover the abbreviated label to recall the exact directory.
const displayLabelTitle = $derived(directory ?? '');
</script>
<span
class:text-foreground={directory}
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
>
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
<Folder class="w-3.5 h-3.5" />
{#if showTooltip && displayLabelTitle}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{displayLabelTitle}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="max-w-64 truncate">{displayLabel}</span>
{/if}
</div>
{#if directory}
<div
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
>
<ActionIcon
ariaLabel="Reset working directory"
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
{disabled}
icon={X}
iconSize="h-3 w-3"
onclick={onClear}
stopPropagationOnClick
tooltip="Reset working directory"
/>
</div>
{/if}
</span>
@@ -0,0 +1,74 @@
<script lang="ts">
import { Folder } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { UI_DATA_ATTRS } from '$lib/constants';
import { highlightMatch } from '$lib/utils';
import { fly } from 'svelte/transition';
// Fly-in transition for the results list.
const FLY_Y_PX = -4;
const FLY_DURATION_MS = 100;
interface Props {
results: string[];
hoveredIndex: number;
isSearching: boolean;
error: string | null;
rawQuery: string;
container?: HTMLDivElement | null;
onCommit?: (path: string) => void;
onHover?: (index: number) => void;
}
let {
container = $bindable(null),
error,
hoveredIndex,
isSearching,
onCommit,
onHover,
rawQuery,
results
}: Props = $props();
</script>
<div
bind:this={container}
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
class="max-h-48 overflow-y-auto py-2"
>
{#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
{:else if error}
<div class="px-2 py-1.5 text-sm text-destructive">{error}</div>
{:else if results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">No matching folders</div>
{:else}
{#each results as path, index (path)}
<button
type="button"
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
class={cn(
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
)}
data-highlighted={index === hoveredIndex ? '' : undefined}
onclick={() => onCommit?.(path)}
onmouseenter={() => onHover?.(index)}
>
<Folder class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-left">
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
{#if seg.match}
<mark class="rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30"
>{seg.text}</mark
>
{:else}
{seg.text}
{/if}
{/each}
</span>
</button>
{/each}
{/if}
</div>
@@ -0,0 +1,78 @@
<script lang="ts">
import ChatFormInputBasic from './ChatFormInputBasic.svelte';
import ChatFormInputRich from './ChatFormInputRich.svelte';
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
useRichInput?: boolean;
}
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
useRichInput = false,
value = $bindable('')
}: Props = $props();
let basicRef: ChatFormInputBasic | undefined = $state();
let richRef: ChatFormInputRich | undefined = $state();
// The two renderers share one imperative handle (focus/caret/height), so
// the parent can drive whichever variant is mounted through this one.
export function getElement() {
return useRichInput ? richRef?.getElement() : basicRef?.getElement();
}
export function focus() {
if (useRichInput) richRef?.focus();
else basicRef?.focus();
}
export function resetHeight() {
if (useRichInput) richRef?.resetHeight();
else basicRef?.resetHeight();
}
export function getCaretOffset(): number {
return useRichInput ? (richRef?.getCaretOffset() ?? 0) : (basicRef?.getCaretOffset() ?? 0);
}
export function setCaretOffset(offset: number) {
if (useRichInput) richRef?.setCaretOffset(offset);
else basicRef?.setCaretOffset(offset);
}
</script>
{#if useRichInput}
<ChatFormInputRich
bind:this={richRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
/>
{:else}
<ChatFormInputBasic
bind:this={basicRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
/>
{/if}
@@ -0,0 +1,82 @@
<script lang="ts">
import { deviceStore } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
}
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
value = $bindable('')
}: Props = $props();
let textareaElement: HTMLTextAreaElement | undefined;
onMount(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
textareaElement.focus({ preventScroll: true });
}
});
export function getElement() {
return textareaElement;
}
export function focus() {
if (deviceStore.isMobile) return;
textareaElement?.focus({ preventScroll: true });
}
export function resetHeight() {
if (textareaElement) {
textareaElement.style.height = '1rem';
}
}
// Plain-text caret offsets, shared with the rich chat form input variant so
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
}
export function setCaretOffset(offset: number) {
textareaElement?.setSelectionRange(offset, offset);
}
</script>
<div class="flex-1 {className}">
<textarea
bind:this={textareaElement}
bind:value
class={[
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
{disabled}
oninput={(event) => {
autoResizeTextarea(event.currentTarget);
onInput?.();
}}
onkeydown={onKeydown}
onpaste={onPaste}
{placeholder}
style="max-height: var(--max-message-height);"
></textarea>
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
interface Props {
class?: string;
multiple?: boolean;
onFileSelect?: (files: File[]) => void;
}
let { class: className = '', multiple = true, onFileSelect }: Props = $props();
let fileInputElement: HTMLInputElement | undefined;
export function click() {
fileInputElement?.click();
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
onFileSelect?.(Array.from(input.files));
}
}
</script>
<input
bind:this={fileInputElement}
class="hidden {className}"
{multiple}
onchange={handleFileSelect}
type="file"
/>
@@ -0,0 +1,867 @@
<script lang="ts">
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
import { deviceStore } from '$lib/stores';
import type { ChatFormInputRichToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
import {
badgeAwareWordJump,
buildFragment,
domMatchesTokens,
highlightCode,
isIMEComposing,
isOffsetInCodeBlock,
leadingBadgeEdgeOffset,
rangeToTextOffset,
serializeContent,
SourceHistory,
stripBlockBoundaryLineBreaks,
syncCodeBlockHatches,
textOffsetToRange,
tokenizeContent
} from '$lib/utils';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { mode } from 'mode-watcher';
import { onDestroy, onMount, untrack } from 'svelte';
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
}
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
value = $bindable('')
}: Props = $props();
let rootElement: HTMLDivElement | undefined = $state();
let lastEmittedValue = '';
let isComposing = $state(false);
// Undo/redo in source space: the imperative token rebuilds destroy the
// browser's native undo stack.
const history = new SourceHistory();
// Browsers disagree on what an empty rich chat form input contains (`<br>`,
// `<div><br></div>`, or nothing), so emptiness is decided by the
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
if (!rootElement) return;
const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? BooleanString.TRUE : BooleanString.FALSE;
}
function renderTokens(tokens: ChatFormInputRichToken[]) {
if (!rootElement) return;
const caret = rangeToTextOffset(rootElement, safeRange());
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.replaceChildren(buildFragment(tokens));
syncCodeBlockHatches(rootElement);
highlightCodeBlocks(rootElement);
restoreCaret(caret);
resizeHeight();
syncEmptyState();
}
// Last highlighted source segment per block element - typing inside
// a block re-highlights only when the segment actually changed.
const highlightedSegments = new WeakMap<HTMLElement, string>();
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
/**
* Apply syntax highlighting to a code block element's CONTENT. The
* fence lines stay plain text, and the blank padding that
* `highlightCode` trims is re-added as plain text, so the element's
* textContent stays byte-exact with the source segment. Replaces
* the element's children - callers restore the caret afterwards.
* Returns false when nothing changed.
*/
function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? '';
if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment);
if (!open) return false;
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
const leading = content.match(CODE_BLOCK.TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(CODE_BLOCK.TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
el.replaceChildren(
document.createTextNode(prefix + leading),
tpl.content.cloneNode(true),
document.createTextNode(trailing + '```')
);
highlightedSegments.set(el, segment);
return true;
}
function highlightCodeBlocks(root: HTMLElement) {
for (const el of root.querySelectorAll<HTMLElement>(
`code[${CODE_TOKEN_ATTR}="${ChatFormInputRichTokenKind.CODE_BLOCK}"]`
)) {
highlightCodeBlockElement(el);
}
}
/**
* Re-highlight the code block the caret sits in after an edit.
* Skipped when the block's segment is unchanged since its last
* highlight, so edits outside blocks cost nothing.
*/
function rehighlightCaretCodeBlock() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
let node: Node | null = range.startContainer;
if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null;
}
while (node && node !== rootElement) {
if (
node instanceof HTMLElement &&
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
) {
const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) {
restoreCaret(caret);
}
return;
}
node = node.parentNode;
}
}
/**
* Is the caret inside a fenced code block region? Source-level
* (not DOM-level) so the still-OPEN fence counts too: while the
* user is typing a block, no closing ``` exists yet and the
* buffer is plain text with no block element to find. Root-level
* caret positions right at a closed block's edge (escape
* hatches, element boundaries restored by `textOffsetToRange`)
* resolve past the closing fence, so they count as OUTSIDE.
*/
function caretInCodeBlock(): boolean {
if (!rootElement) return false;
return isOffsetInCodeBlock(
serializeContent(rootElement),
rangeToTextOffset(rootElement, safeRange())
);
}
/**
* hljs theme for the highlighted code blocks. Mirrors
* SyntaxHighlightedCode.svelte: one shared style element
* (deduped via the data attribute) swapped on mode change.
*/
function loadHighlightTheme(isDark: boolean) {
document
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
.forEach((s) => s.remove());
const style = document.createElement('style');
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
$effect(() => {
loadHighlightTheme(mode.current === ColorMode.DARK);
});
function safeRange(): Range | null {
if (!rootElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
return null;
}
return range;
}
function restoreCaret(offset: number, extend = false) {
if (!rootElement) return;
const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection();
if (!selection) return;
if (extend && selection.anchorNode) {
selection.setBaseAndExtent(
selection.anchorNode,
selection.anchorOffset,
target.startContainer,
target.startOffset
);
return;
}
selection.removeAllRanges();
selection.addRange(target);
}
function resizeHeight() {
if (!rootElement) return;
rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`;
}
function recordHistory(newGroup: boolean) {
if (!rootElement) return;
history.push(
{ caret: rangeToTextOffset(rootElement, safeRange()), value: lastEmittedValue },
Date.now(),
newGroup
);
}
/**
* Re-emit the current markdown source value to the parent, then
* reconcile the DOM against the token stream: when a code span
* was just completed or broken, the token boundaries no longer
* match the element structure and the DOM is rebuilt (caret
* preserved through the source-offset mapping).
*/
function processInput(inputType?: string) {
if (isComposing || !rootElement) return;
syncEmptyState();
resizeHeight();
// Shift+Enter right after a code block leaves an all-newline
// text node (the fence's separator line plus Chromium's
// artificial end-of-buffer line break). Strip both so the caret
// lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange());
if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret);
} else {
const source = serializeContent(rootElement);
let end = caret;
// the caret must end up after the inserted \n; some browsers
// leave it before (stuck at the end of the old line). A
// preceding \n means it already sits past the break
// (Chromium's artificial trailing newline) - leave it.
if (source[end] === '\n' && source[end - 1] !== '\n') {
end += 1;
restoreCaret(end);
}
// a line break at the buffer end renders only with a second,
// artificial trailing \n: a lone trailing \n is collapsed, so
// the new line is invisible and the next typed character
// consumes it. Append it when missing - unless the trailing
// \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element.
let last = rootElement.lastChild;
while (last && last.nodeName === 'BR') last = last.previousSibling;
if (
end === source.length &&
source.endsWith('\n') &&
source[source.length - 2] !== '\n' &&
last?.nodeType === Node.TEXT_NODE
) {
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.appendChild(document.createTextNode('\n'));
restoreCaret(source.length);
resizeHeight();
}
}
}
syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement);
syncEmptyState(serialized);
if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits
// (paste, newline, cut, autocorrect) start a new undo group.
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
lastEmittedValue = serialized;
value = serialized;
// Rebuild when token boundaries shifted (a code span was just
// completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized);
if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens);
// The rebuild can re-shape the DOM in a way that changes the
// serialization (e.g. Chromium merged trailing text into the
// block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement);
if (reserialized !== serialized) {
lastEmittedValue = reserialized;
value = reserialized;
}
} else {
rehighlightCaretCodeBlock();
}
onInput?.();
}
function handleInput(event: Event) {
processInput((event as InputEvent).inputType);
}
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
processInput();
}
/**
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
* the buffer end varies across browsers (a lone trailing \n that the
* renderer collapses, or a <br> that the hatch sync strips), which
* can leave the caret stuck on the old line; splitting the text node
* ourselves keeps the DOM shape - and the caret - deterministic.
* `processInput` then appends the artificial trailing \n when the
* break lands at the buffer end.
*/
function insertLineBreak() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
if (!range.collapsed) {
range.deleteContents();
}
const container = range.startContainer;
const offset = range.startOffset;
const nl = document.createTextNode('\n');
// a break at the very end of a code block exits the block (the
// new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null;
if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode;
while (node && node !== rootElement) {
if (
node instanceof HTMLElement &&
node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
) {
const tail = document.createRange();
tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length);
if (tail.toString().length === 0) exitBlock = node;
break;
}
node = node.parentNode;
}
}
if (exitBlock) {
exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
if (offset === 0) {
text.before(nl);
} else if (offset === text.length) {
text.after(nl);
} else {
text.splitText(offset).before(nl);
}
} else {
container.insertBefore(nl, container.childNodes[offset] ?? null);
}
const selection = window.getSelection();
const after = document.createRange();
after.setStartAfter(nl);
after.collapse(true);
selection?.removeAllRanges();
selection?.addRange(after);
processInput('insertLineBreak');
}
/**
* Arrow escape to the line BEFORE a leading code block. Native
* caret movement has no position above a buffer-starting block,
* so a transient `<br>` hatch is created on demand: it gives the
* caret a visible line, is consumed by the first character typed
* on it, and is removed again when the caret leaves (see
* handleSelectionChange). Returns true when the caret was moved.
*/
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
if (!rootElement) return false;
// a hatch already exists - native movement handles it
if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild;
if (
!(first instanceof HTMLElement) ||
first.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
)
return false;
const range = safeRange();
if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first
// character for ArrowLeft, anywhere on its first line for
// ArrowUp
if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range);
if (key === 'ArrowLeft') {
if (caret !== 0) return false;
} else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the rich chat form input host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
return true;
}
/**
* Remove the transient leading hatch once the caret leaves it.
* The hatch only exists to give the caret a line above a leading
* code block; with the caret anywhere else the empty line would
* just be visual noise. Typing on the hatch line consumes it via
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
* new text node takes its place before the block).
*/
function handleSelectionChange() {
if (!rootElement) return;
const first = rootElement.firstChild;
if (first?.nodeName !== 'BR') return;
const second = first.nextSibling;
if (
!(second instanceof HTMLElement) ||
second.getAttribute(CODE_TOKEN_ATTR) !== ChatFormInputRichTokenKind.CODE_BLOCK
)
return;
const range = safeRange();
const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0;
if (!onHatch) {
first.remove();
}
}
/**
* Undo/redo is replayed from source snapshots (the token rebuilds
* destroy the native undo stack). Arrow keys around badges are
* repaired locally: a badge is a non-editable island, so plain
* ArrowLeft after a leading badge has no native previous position
* and word jumps overshoot it by a word.
*
* Plain Enter inside a fenced code block (closed, or still open
* while being typed) acts as Shift+Enter and adds a line instead of
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
* create the transient before-block hatch.
*/
function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (isUndo || isRedo) {
event.preventDefault();
const current = {
caret: rangeToTextOffset(rootElement, safeRange()),
value: lastEmittedValue
};
const entry = isUndo ? history.undo(current) : history.redo(current);
if (entry) applyHistoryEntry(entry);
return;
}
}
if (
event.key === 'Enter' &&
event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
!disabled &&
!caretInCodeBlock() &&
safeRange()
) {
// Own the break outside code blocks: native end-of-buffer
// behavior varies across browsers and can leave the caret
// stuck on the old line (see insertLineBreak).
event.preventDefault();
insertLineBreak();
return;
}
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
caretInCodeBlock()
) {
// The native plain-Enter path must never run: it splits the
// buffer into `<div>` wrappers that `serializeContent` cannot
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
// text node) and fires `input` synchronously, so the usual
// re-tokenize/re-highlight follows.
event.preventDefault();
document.execCommand('insertLineBreak');
return;
}
if (
rootElement &&
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault();
return;
}
}
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey && !event.shiftKey;
const isPlainLeft =
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
if (isWordJump || isPlainLeft) {
const source = serializeContent(rootElement);
const caret = rangeToTextOffset(rootElement, safeRange());
const target = isWordJump
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
: leadingBadgeEdgeOffset(source, caret);
if (target !== null) {
event.preventDefault();
restoreCaret(target, event.shiftKey);
return;
}
}
}
onKeydown?.(event);
}
// lastEmittedValue is set before `value` so the sync effect treats the
// change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return;
renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value;
value = entry.value;
onInput?.();
restoreCaret(entry.caret);
}
/**
* Plain-text paste. preventDefault + manual insertText keeps the
* browser from producing stray `<div>` wrappers mid-paste; insertText
* fires `input` synchronously, so `processInput` re-tokenizes the
* buffer and rebuilds when the pasted text carries badge or code
* tokens.
*/
function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain');
if (pasted && pasted.length > 0) {
event.preventDefault();
// Snap a collapsed caret through the offset mapping first: at
// element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace.
const range = safeRange();
if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range));
}
document.execCommand('insertText', false, pasted);
}
}
// The parent's paste handler runs first and preventDefaults when it
// consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) {
onPaste?.(event);
if (!event.defaultPrevented) {
handlePasteEvent(event);
}
}
// The selection as markdown SOURCE (each badge contributes its full
// `[name](file://...)` link), so copy/cut carry raw markdown and
// pasting back re-renders the badges. Null for collapsed/outside
// selections - native clipboard behavior is fine there.
function selectionSourceSlice(): { text: string; range: Range } | null {
if (!rootElement) return null;
const range = safeRange();
if (!range || range.collapsed) return null;
const startRange = range.cloneRange();
startRange.collapse(true);
const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range);
return { range, text: source.slice(start, end) };
}
function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
}
function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
// preventDefault suppresses the native deletion, so remove the
// selection manually and re-emit.
slice.range.deleteContents();
processInput('deleteByCut');
}
onMount(() => {
// untrack: the DOM is managed manually from input events, so the
// initial render must not subscribe to the value.
renderTokens(tokenizeContent(untrack(() => value)));
lastEmittedValue = untrack(() => value ?? '');
resizeHeight();
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!deviceStore.isMobile) {
rootElement?.focus({ preventScroll: true });
}
});
onDestroy(() => {
document.removeEventListener('selectionchange', handleSelectionChange);
});
// External `value` updates. When incoming === lastEmittedValue the
// change came from our own input, so leave the DOM alone - the
// browser already owns the right shape.
$effect(() => {
const incoming = value ?? '';
if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
renderTokens(tokenizeContent(incoming));
lastEmittedValue = incoming;
});
export function getElement() {
return rootElement;
}
export function getCaretOffset(): number {
if (!rootElement) return 0;
return rangeToTextOffset(rootElement, safeRange());
}
// Focus first: `selection.addRange` requires it on some browsers.
export function setCaretOffset(offset: number) {
if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true });
}
restoreCaret(offset);
}
export function focus() {
if (deviceStore.isMobile) return;
rootElement?.focus({ preventScroll: true });
}
export function resetHeight() {
if (rootElement) {
rootElement.style.height = '';
resizeHeight();
}
}
</script>
<div class="flex-1 {className} mb-0.5">
<div
bind:this={rootElement}
aria-disabled={disabled}
aria-multiline="true"
aria-placeholder={placeholder}
class={[
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
contenteditable={!disabled}
data-placeholder={placeholder}
oncompositionend={handleCompositionEnd}
oncompositionstart={handleCompositionStart}
oncopy={handleCopy}
oncut={handleCut}
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
role="textbox"
style="max-height: var(--max-message-height);"
tabindex={disabled ? -1 : 0}
></div>
</div>
<style>
/* pre-wrap is load-bearing: without it Chromium collapses \n in
text nodes and converts them to spaces while typing */
.chat-form-input-rich {
white-space: pre-wrap;
}
.chat-form-input-rich:global([data-empty='true'])::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
/* Inline code - mirrors markdown-content.css */
.chat-form-input-rich :global(code[data-code-token='code_inline']) {
background: var(--muted);
color: var(--muted-foreground);
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.875rem;
}
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
.chat-form-input-rich :global(code[data-code-token='code_block']) {
display: block;
margin: 0.25rem 0;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
border-radius: 0.75rem;
background: var(--code-background);
color: var(--code-foreground);
font-size: 0.875rem;
line-height: 1.3;
}
</style>
@@ -0,0 +1,38 @@
<script lang="ts">
import { ChatAttachmentsListItemMcpResource, ScrollCarousel } from '$lib/components/app';
import { ScrollCarouselVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores';
interface Props {
class?: string;
onResourceClick?: (uri: string) => void;
}
let { class: className, onResourceClick }: Props = $props();
const attachments = $derived(mcpStore.resources.attachments);
const hasAttachments = $derived(mcpStore.resources.hasAttachments);
function handleRemove(attachmentId: string) {
mcpStore.removeResourceAttachment(attachmentId);
}
function handleResourceClick(uri: string) {
onResourceClick?.(uri);
}
</script>
{#if hasAttachments}
<div class={className}>
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.CENTER}>
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentsListItemMcpResource
{attachment}
class={i === 0 ? 'ml-3' : ''}
onRemove={handleRemove}
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</ScrollCarousel>
</div>
{/if}
@@ -0,0 +1,55 @@
<script lang="ts">
import { mcpStore } from '$lib/stores';
import type { MCPServerSettingsEntry } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
server: MCPServerSettingsEntry | undefined;
serverLabel: string;
title: string;
description?: string;
titleExtra?: Snippet;
subtitle?: Snippet;
}
let { description, server, serverLabel, subtitle, title, titleExtra }: Props = $props();
let faviconUrl = $derived(server ? mcpStore.getServerFavicon(server.id) : null);
</script>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
<span>{serverLabel}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{title}
</span>
{#if titleExtra}
{@render titleExtra()}
{/if}
</div>
{#if description}
<p class="mt-0.5 truncate text-sm text-muted-foreground">
{description}
</p>
{/if}
{#if subtitle}
{@render subtitle()}
{/if}
</div>
@@ -0,0 +1,112 @@
<script generics="T" lang="ts">
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import type { Snippet } from 'svelte';
interface Props {
items: T[];
isLoading: boolean;
selectedIndex: number;
searchQuery: string;
showSearchInput: boolean;
searchPlaceholder?: string;
// Omit to distinguish "haven't searched yet" from "search returned nothing".
emptyMessage?: string;
autofocus?: boolean;
inputRef?: HTMLInputElement | null;
onSearchClose?: () => void;
itemKey: (item: T, index: number) => string;
item: Snippet<[T, number, boolean]>;
skeleton?: Snippet;
skeletonCount?: number;
footer?: Snippet;
// Counter bumped by the picker on keyboard nav; scrolls the selected
// row into view without scrolling on hover or result replacement.
scrollTrigger?: number;
}
let {
autofocus = false,
emptyMessage,
footer,
inputRef = $bindable(null),
isLoading,
item,
itemKey,
items,
onSearchClose,
scrollTrigger,
searchPlaceholder = 'Search...',
searchQuery = $bindable(),
selectedIndex,
showSearchInput,
skeleton,
skeletonCount = 6
}: Props = $props();
let listContainer = $state<HTMLDivElement | null>(null);
let listPaddingTop = $derived(
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
);
// selectedIndex/items.length are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
useScrollActiveRow({
dataAttr: UI_DATA_ATTRS.PICKER_INDEX,
getContainer: () => listContainer,
getCount: () => items.length,
getIndex: () => selectedIndex,
getTrigger: () => scrollTrigger
});
</script>
<ScrollArea>
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput
bind:ref={inputRef}
bind:value={searchQuery}
{autofocus}
onClose={onSearchClose}
placeholder={searchPlaceholder}
/>
</div>
{/if}
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{:else}
<div aria-busy="true" aria-live="polite" class="flex flex-col">
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
</div>
</div>
{/each}
</div>
{/if}
{:else if items && items.length === 0}
{#if emptyMessage}
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
{:else}
{#each items as itemData, index (itemKey(itemData, index))}
{@render item(itemData, index, index === selectedIndex)}
{/each}
{/if}
</div>
{#if footer}
{@render footer()}
{/if}
</ScrollArea>
@@ -0,0 +1,37 @@
<script lang="ts">
import { UI_DATA_ATTRS } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
isSelected?: boolean;
disabled?: boolean;
onclick: () => void;
onmouseenter?: () => void;
dataIndex?: number;
children: Snippet;
class?: string;
}
let {
children,
class: className = '',
dataIndex,
disabled = false,
isSelected = false,
onclick,
onmouseenter
}: Props = $props();
</script>
<button
{disabled}
{onclick}
{onmouseenter}
type="button"
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
>
{@render children()}
</button>
@@ -0,0 +1,31 @@
<script lang="ts">
interface Props {
titleWidth?: string;
showBadge?: boolean;
}
let { showBadge = false, titleWidth = 'w-48' }: Props = $props();
</script>
<div class="flex w-full items-start gap-3 rounded-lg px-3 py-2">
<div class="min-w-0 flex-1 space-y-2">
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
<!-- Title skeleton -->
<div class="flex items-center gap-2">
<div class="h-4 {titleWidth} animate-pulse rounded bg-muted"></div>
{#if showBadge}
<div class="h-4 w-12 animate-pulse rounded-full bg-muted"></div>
{/if}
</div>
<!-- Description skeleton -->
<div class="h-3 w-full animate-pulse rounded bg-muted"></div>
</div>
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
isOpen?: boolean;
srLabel?: string;
onClose?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
children: Snippet;
}
let {
children,
class: className = '',
isOpen = $bindable(false),
onClose,
onKeydown,
srLabel = 'Open picker'
}: Props = $props();
</script>
<Popover.Root
bind:open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose?.();
}
}}
>
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
>
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
align="start"
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={onKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
{@render children()}
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,136 @@
<script lang="ts">
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
import { CHAT_FORM_COMMAND_ICONS } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { ChatFormCommand } from '$lib/types';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
* The parent owns the "dismissed token, don't act until it changes"
* snapshot, so this picker just renders and reports selection.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
commands: ChatFormCommand[];
onClose: () => void;
onSelect: (command: ChatFormCommand) => void;
}
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
trimmedQuery
? commands.filter(
(c) =>
c.name.toLowerCase().includes(trimmedQuery) ||
c.description.toLowerCase().includes(trimmedQuery) ||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
)
: commands
);
function firstEnabledIndex(): number {
return filteredCommands.findIndex((c) => !c.disabled);
}
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
if (n === 0) return -1;
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx;
}
return -1;
}
const nav = usePickerNavigation({
count: () => filteredCommands.length,
isOpen: () => isOpen,
onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index]),
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir))
});
$effect(() => {
if (isOpen) {
nav.reset(firstEnabledIndex());
}
});
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
return;
}
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
});
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
onSelect(command);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
{onClose}
onKeydown={handleKeydown}
srLabel="Open command picker"
>
<ChatFormPickerList
emptyMessage="No matching command"
isLoading={false}
itemKey={(command) => command.name}
items={filteredCommands}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
>
{#snippet item(command, index, isSelected)}
{@const Icon = CHAT_FORM_COMMAND_ICONS[command.action]}
<ChatFormPickerListItem
dataIndex={index}
disabled={command.disabled}
{isSelected}
onclick={() => handleSelect(command)}
onmouseenter={() => {
if (!command.disabled) nav.setHover(index);
}}
>
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-mono text-sm font-medium">/{command.name}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
{command.description}
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -0,0 +1,446 @@
<script lang="ts">
import {
ChatFormPickerItemHeader,
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerListItemSkeleton,
ChatFormPickerPopover,
ChatFormPromptPickerArgumentForm
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
import { mcpStore } from '$lib/stores';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
class: className = '',
isOpen = false,
onClose,
onPromptLoadComplete,
onPromptLoadError,
onPromptLoadStart,
searchQuery = ''
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
let isLoading = $state(false);
let selectedPrompt = $state<MCPPromptInfo | null>(null);
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
// nav but not on hover or result changes.
let scrollTrigger = $state(0);
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(0);
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
$effect(() => {
if (isOpen) {
loadPrompts();
selectedIndex = 0;
} else {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
});
$effect(() => {
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
selectedIndex = 0;
}
});
async function loadPrompts() {
isLoading = true;
try {
const initialized = await mcpStore.ensureInitialized();
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpStore.getAllPrompts();
} catch (error) {
console.error('[ChatFormPickerMcpPrompts] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
}
}
function handlePromptClick(prompt: MCPPromptInfo) {
const args = prompt.arguments ?? [];
if (args.length > 0) {
selectedIndexBeforeArgumentForm = selectedIndex;
selectedPrompt = prompt;
promptArgs = {};
promptError = null;
requestAnimationFrame(() => {
const firstInput = document.querySelector(`#arg-${args[0].name}`) as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
});
} else {
executePrompt(prompt, {});
}
}
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
promptError = null;
const placeholderId = uuid();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
onClose?.();
try {
const result = await mcpStore.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
function handleArgumentSubmit(event: SubmitEvent) {
event.preventDefault();
if (selectedPrompt) {
executePrompt(selectedPrompt, promptArgs);
}
}
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (!selectedPrompt || value.length < 1) {
suggestions[argName] = [];
return;
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
argName,
promptName: selectedPrompt.name,
serverName: selectedPrompt.serverName,
value
});
}
loadingSuggestions[argName] = true;
try {
const result = await mcpStore.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,
value
);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
result,
suggestionsCount: result?.values.length ?? 0,
value
});
}
if (result && result.values.length > 0) {
// Filter out empty strings from suggestions
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPickerMcpPrompts] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
promptArgs[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
promptArgs[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
// Handle Escape - return to prompt selection list
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
event.stopPropagation();
handleCancelArgumentForm();
return;
}
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
}
}
function handleArgBlur(argName: string) {
// Delay to allow click on suggestion
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
function handleArgFocus(argName: string) {
if ((suggestions[argName]?.length ?? 0) > 0) {
activeAutocomplete = argName;
}
}
function handleCancelArgumentForm() {
// Restore the previously selected prompt index
if (selectedIndexBeforeArgumentForm !== null) {
selectedIndex = selectedIndexBeforeArgumentForm;
selectedIndexBeforeArgumentForm = null;
}
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
if (selectedPrompt) {
// Return to prompt selection list, keeping the selected prompt active
handleCancelArgumentForm();
} else {
onClose?.();
}
return true;
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
}
return true;
}
if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
}
return true;
}
if (event.key === KeyboardKey.ENTER && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
return true;
}
return false;
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServers();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(query) ||
prompt.title?.toLowerCase().includes(query) ||
prompt.description?.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(prompts.length > 3);
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
{onClose}
onKeydown={handleKeydown}
srLabel="Open prompt picker"
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<div class="p-4">
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
{activeAutocomplete}
{autocompleteIndex}
{loadingSuggestions}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onCancel={handleCancelArgumentForm}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
prompt={selectedPrompt}
{promptArgs}
{promptError}
{suggestions}
/>
</div>
{:else}
<ChatFormPickerList
bind:searchQuery={internalSearchQuery}
emptyMessage="No MCP prompts available"
{isLoading}
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
items={filteredPrompts}
{scrollTrigger}
searchPlaceholder="Search prompts..."
{selectedIndex}
{showSearchInput}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
{@const serverLabel = server ? mcpStore.getServerLabel(server) : prompt.serverName}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
<Badge variant="secondary">
{prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''}
</Badge>
{/if}
{/snippet}
</ChatFormPickerItemHeader>
</ChatFormPickerListItem>
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton showBadge titleWidth="w-32" />
{/snippet}
</ChatFormPickerList>
{/if}
</ChatFormPickerPopover>
@@ -0,0 +1,74 @@
<script lang="ts">
import ChatFormPromptPickerArgumentInput from './ChatFormPromptPickerArgumentInput.svelte';
import { Button } from '$lib/components/ui/button';
import type { MCPPromptInfo } from '$lib/types';
interface Props {
prompt: MCPPromptInfo;
promptArgs: Record<string, string>;
suggestions: Record<string, string[]>;
loadingSuggestions: Record<string, boolean>;
activeAutocomplete: string | null;
autocompleteIndex: number;
promptError: string | null;
onArgInput: (argName: string, value: string) => void;
onArgKeydown: (event: KeyboardEvent, argName: string) => void;
onArgBlur: (argName: string) => void;
onArgFocus: (argName: string) => void;
onSelectSuggestion: (argName: string, value: string) => void;
onSubmit: (event: SubmitEvent) => void;
onCancel: () => void;
}
let {
activeAutocomplete,
autocompleteIndex,
loadingSuggestions,
onArgBlur,
onArgFocus,
onArgInput,
onArgKeydown,
onCancel,
onSelectSuggestion,
onSubmit,
prompt,
promptArgs,
promptError,
suggestions
}: Props = $props();
</script>
<form class="space-y-3 pt-4" onsubmit={onSubmit}>
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
isAutocompleteActive={activeAutocomplete === arg.name}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
suggestions={suggestions[arg.name] ?? []}
value={promptArgs[arg.name] ?? ''}
/>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0">⚠</span>
<span>{promptError}</span>
</div>
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button onclick={onCancel} size="sm" type="button" variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
</form>
@@ -0,0 +1,84 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import type { MCPPromptInfo } from '$lib/types';
import { fly } from 'svelte/transition';
type PromptArgument = NonNullable<MCPPromptInfo['arguments']>[number];
interface Props {
argument: PromptArgument;
value: string;
suggestions?: string[];
isLoadingSuggestions?: boolean;
isAutocompleteActive?: boolean;
autocompleteIndex?: number;
onInput: (value: string) => void;
onKeydown: (event: KeyboardEvent) => void;
onBlur: () => void;
onFocus: () => void;
onSelectSuggestion: (value: string) => void;
}
let {
argument,
autocompleteIndex = 0,
isAutocompleteActive = false,
isLoadingSuggestions = false,
onBlur,
onFocus,
onInput,
onKeydown,
onSelectSuggestion,
suggestions = [],
value = ''
}: Props = $props();
</script>
<div class="relative grid gap-1">
<Label class="mb-1 text-muted-foreground" for="arg-{argument.name}">
<span>
{argument.name}
{#if argument.required}
<span class="text-destructive">*</span>
{/if}
</span>
{#if isLoadingSuggestions}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</Label>
<Input
autocomplete="off"
id="arg-{argument.name}"
onblur={onBlur}
onfocus={onFocus}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
placeholder={argument.description || argument.name}
required={argument.required}
type="text"
{value}
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
transition:fly={{ duration: 100, y: -5 }}
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
>
{#each suggestions as suggestion, i (suggestion)}
<button
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
onmousedown={() => onSelectSuggestion(suggestion)}
type="button"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,291 @@
<script lang="ts">
import { File, Folder } from '@lucide/svelte';
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants';
import {
BuiltInTool,
FileMentionEntryType,
GlobSearchType,
KeyboardKey,
ToolSource
} from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { conversationsStore, deviceStore, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
/**
* Floating file/folder mention picker. The chat input is the search
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
* call scoped to `scopePath`. The parent owns the "dismissed token,
* don't re-open until it changes" snapshot.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
customAnchor?: HTMLElement | null;
scopePath?: string | null;
onClose: () => void;
onSelect: (entry: FileMentionEntry) => void;
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
onOpened?: () => void;
}
let {
class: className = '',
customAnchor = null,
isOpen,
onClose,
onOpened,
onSelect,
query,
scopePath = null
}: Props = $props();
const nav = usePickerNavigation({
count: () => displayedItems.length,
isOpen: () => isOpen,
onClose: () => onClose(),
onSelect: (index) => handleSelect(displayedItems[index])
});
// When the server does not expose file_glob_search (started without
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
let searchResults = $state<FileMentionEntry[]>([]);
let searchError = $state<string | null>(null);
// Coerce the depth setting to a positive integer; an invalid value
// would otherwise reach the server as max_depth 0 = unlimited.
const searchDepth = $derived.by(() => {
const n = Number(settingsStore.config.mentionSearchMaxDepth);
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS.DEFAULT_SEARCH_DEPTH;
});
const home = $derived(toolsStore.serverHome);
// A smaller window than the WD picker suffices: entries are ranked client-side.
const MENTION_SEARCH_LIMIT = 50;
const search = useDebouncedSearch({
canRun: () => isOpen && fileSearchEnabled,
debounceMs: SEARCH.DEBOUNCE_MS,
getQuery: () => trimmedQuery,
run: async (query, signal, isCurrent) => {
try {
// A trailing path separator targets a directory, so also list its
// children. Accept both `/` and `\`.
const res = await runGlobSearchWithChildren(
query,
scopePath ?? home ?? HOME_TILDE,
searchDepth,
MENTION_SEARCH_LIMIT,
signal,
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
);
if (!isCurrent()) return;
if (res.error) {
searchResults = [];
searchError = res.error;
return;
}
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
name: e.name,
path: e.path,
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
});
searchResults = res.entries.map(toEntry);
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
searchResults = [];
searchError = err instanceof Error ? err.message : String(err);
}
}
});
const trimmedQuery = $derived((query ?? '').trim());
const displayedItems = $derived(searchResults);
const emptyMessage = $derived.by(() => {
if (fileSearchKey === null) {
return 'File search is unavailable on this server (started without --tools)';
}
if (!fileSearchEnabled) {
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
}
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!deviceStore.isMobile);
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
$effect(() => {
if (isOpen) {
nav.reset(0);
}
});
$effect(() => {
if (isOpen) onOpened?.();
});
$effect(() => {
const q = (query ?? '').trim();
if (!isOpen || !q || !fileSearchEnabled) {
search.cancel();
searchResults = [];
searchError = null;
return;
}
search.setLoading(true);
search.run(q);
});
function handleSelect(entry: FileMentionEntry) {
onSelect(entry);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
// Always consume Enter while the picker is open - even with no
// result yet (skeletons) or no matches - so the chat form's
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
return true;
}
return nav.handleKeydown(event);
}
</script>
<Popover.Root
onOpenChange={(open) => {
if (!open) onClose();
}}
open={isOpen}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
We open programmatically via `open={isOpen}`, so it is inert
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<ChatFormPickerList
{emptyMessage}
isLoading={search.isSearching}
itemKey={(entry) => entry.type + ':' + entry.path}
items={displayedItems}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
onclick={() => handleSelect(entry)}
onmouseenter={() => nav.setHover(index)}
>
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
<Icon
class={[
'mt-0.5 h-4 w-4 shrink-0',
entry.type === FileMentionEntryType.DIRECTORY
? 'text-amber-500'
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch query={trimmedQuery} text={abbreviateHome(entry.path, home)} />
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,108 @@
<script lang="ts">
import ChatFormPickerCommand from './ChatFormPickerCommand.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import ChatFormPickerMention from './ChatFormPickerMention.svelte';
import type {
ChatFormCommand,
FileMentionEntry,
GetPromptResult,
MCPPromptInfo
} from '$lib/types';
interface Props {
isCommandPickerOpen?: boolean;
commandQuery?: string;
commands?: ChatFormCommand[];
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onCommandPickerClose?: () => void;
onCommandSelect?: (command: ChatFormCommand) => void;
onPromptPickerClose?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
onMentionSelect?: (entry: FileMentionEntry) => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
commandQuery,
commands = [],
isCommandPickerOpen,
isMentionPickerOpen,
isPromptPickerOpen,
mentionAnchor,
mentionQuery,
onCommandPickerClose,
onCommandSelect,
onMentionOpened,
onMentionPickerClose,
onMentionSelect,
onPromptLoadComplete,
onPromptLoadError,
onPromptLoadStart,
onPromptPickerClose,
promptSearchQuery,
scopePath
}: Props = $props();
let commandPickerRef: ChatFormPickerCommand | undefined = $state(undefined);
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let mentionPickerRef: ChatFormPickerMention | undefined = $state(undefined);
/** Delegate keyboard events to the active picker child; true if handled. */
export function handleKeydown(event: KeyboardEvent): boolean {
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
return true;
}
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return true;
}
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
return true;
}
return false;
}
</script>
<ChatFormPickerCommand
bind:this={commandPickerRef}
{commands}
isOpen={isCommandPickerOpen ?? false}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
query={commandQuery ?? ''}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
onClose={onPromptPickerClose}
{onPromptLoadComplete}
{onPromptLoadError}
{onPromptLoadStart}
searchQuery={promptSearchQuery}
/>
<ChatFormPickerMention
bind:this={mentionPickerRef}
customAnchor={mentionAnchor}
isOpen={isMentionPickerOpen ?? false}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
onSelect={onMentionSelect ?? (() => {})}
query={mentionQuery ?? ''}
scopePath={scopePath ?? null}
/>
@@ -0,0 +1,427 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
ChatMessageAssistant,
ChatMessageMcpPrompt,
ChatMessageSynthetic,
ChatMessageSystem,
ChatMessageUser
} from '$lib/components/app/chat';
import {
AGENTIC_TEXT_COPY_SEPARATOR,
REASONING_TAGS,
ROUTES,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type {
ChatMessageActions,
ChatMessageDeletionInfo,
DatabaseMessageExtraMcpPrompt
} from '$lib/types';
import { deriveAgenticSections } from '$lib/utils';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props {
class?: string;
chatActions: ChatMessageActions;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean;
isLastUserMessage?: boolean;
nextAssistantMessage?: DatabaseMessage | null;
siblingInfo?: ChatMessageSiblingInfo | null;
}
let {
chatActions,
class: className = '',
isLastAssistantMessage = false,
isLastUserMessage = false,
message,
nextAssistantMessage = null,
siblingInfo = null,
toolMessages = []
}: Props = $props();
let deletionInfo = $state<ChatMessageDeletionInfo | null>(null);
// The system message placeholder must never surface as editable content; keeping
// it in the derived (not just in handleEdit) guards against prop invalidation
// reverting the override while editing
let editedContent = $derived(
message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
? ''
: message.content
);
// Synthetic cwd-change messages render with the folder-row UI instead
// of a user bubble. The persisted flag is the single source of truth.
let isSynthetic = $derived(Boolean(message.isSynthetic));
let rawEditContent = $derived.by(() => {
if (message.role !== MessageRole.ASSISTANT) return undefined;
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 editedExtras = $derived<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
let isEditing = $state(false);
let showDeleteDialog = $state(false);
let shouldBranchAfterEdit = $state(false);
let textareaElement: HTMLTextAreaElement | undefined = $state();
let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setChatMessageEditContext({
cancel: handleCancelEdit,
get editedContent() {
return editedContent;
},
get editedExtras() {
return editedExtras;
},
get editedUploadedFiles() {
return editedUploadedFiles;
},
get isEditing() {
return isEditing;
},
get messageRole() {
return message.role;
},
get originalContent() {
return message.role === MessageRole.ASSISTANT
? (rawEditContent ?? message.content)
: message.content;
},
get originalExtras() {
return message.extra || [];
},
get rawEditContent() {
return rawEditContent;
},
save: handleSaveEdit,
saveOnly: handleSaveEditOnly,
setContent: (content: string) => {
editedContent = content;
},
setExtras: (extras: DatabaseMessageExtra[]) => {
editedExtras = extras;
},
setShouldBranchAfterEdit: (value: boolean) => {
shouldBranchAfterEdit = value;
},
setUploadedFiles: (files: ChatUploadedFile[]) => {
editedUploadedFiles = files;
},
get shouldBranchAfterEdit() {
return shouldBranchAfterEdit;
},
get showBranchAfterEditOption() {
return showBranchAfterEditOption;
},
get showSaveOnlyOption() {
return showSaveOnlyOption;
},
startEdit: handleEdit
});
setChatMessageActionsContext({
confirmDelete: handleConfirmDelete,
copy: handleCopy,
get deletionInfo() {
return deletionInfo;
},
get forkConversation() {
const isForkableUser = message.role === MessageRole.USER && !mcpPromptExtra;
return isForkableUser || message.role === MessageRole.ASSISTANT
? handleForkConversation
: undefined;
},
navigateToSibling: handleNavigateToSibling,
requestDelete: handleDelete,
setShowDeleteDialog: handleShowDeleteDialogChange,
get showDeleteDialog() {
return showDeleteDialog;
},
get siblingInfo() {
return siblingInfo;
}
});
let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null;
if (message.content.trim()) return null;
if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0];
if (extra.type === AttachmentType.MCP_PROMPT) {
return extra as DatabaseMessageExtraMcpPrompt;
}
return null;
});
$effect(() => {
const pendingId = chatStore.pendingEditMessageId;
if (pendingId && pendingId === message.id && !isEditing) {
handleEdit();
chatStore.clearPendingEditMessageId();
}
});
async function handleCancelEdit() {
isEditing = false;
// If canceling a new system message with placeholder content, remove it without deleting children
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(ROUTES.START);
}
return;
}
editedContent =
message.role === MessageRole.ASSISTANT
? rawEditContent || message.content || ''
: message.content;
editedExtras = message.extra ? [...message.extra] : [];
editedUploadedFiles = [];
}
function handleCopy() {
// Agentic sessions render as a single entry anchored on the first assistant
// turn, whose own content is typically just the first tool call. Copy the
// text sections of the whole session so the clipboard matches the visible
// response instead of the anchor turn.
if (message.role === MessageRole.ASSISTANT) {
const sections = deriveAgenticSections(message, toolMessages, [], false);
const text = sections
.filter((section) => section.type === AgenticSectionType.TEXT)
.map((section) => section.content)
.join(AGENTIC_TEXT_COPY_SEPARATOR);
if (text) {
chatActions.copy(message, text);
return;
}
}
chatActions.copy(message);
}
async function handleConfirmDelete() {
if (message.role === MessageRole.SYSTEM) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(ROUTES.START);
}
} else {
chatActions.delete(message);
}
showDeleteDialog = false;
}
async function handleDelete() {
deletionInfo = await chatStore.getDeletionInfo(message.id);
showDeleteDialog = true;
}
function handleEdit() {
isEditing = true;
// Clear temporary placeholder content for system messages
if (message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER) {
editedContent = '';
} else if (message.role === MessageRole.ASSISTANT) {
editedContent = rawEditContent || message.content || '';
} else {
editedContent = message.content;
}
textareaElement?.focus({ preventScroll: true });
editedExtras = message.extra ? [...message.extra] : [];
editedUploadedFiles = [];
setTimeout(() => {
if (textareaElement) {
textareaElement.focus();
textareaElement.setSelectionRange(
textareaElement.value.length,
textareaElement.value.length
);
}
}, 0);
}
function handleRegenerate(modelOverride?: string) {
chatActions.regenerateWithBranching(message, modelOverride);
}
function handleContinue() {
chatActions.continueAssistantMessage(message);
}
function handleForkConversation(options: { name: string; includeAttachments: boolean }) {
chatActions.forkConversation(message, options);
}
function handleNavigateToSibling(siblingId: string) {
chatActions.navigateToSibling(siblingId);
}
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (deviceStore.isMobile) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
async function handleSaveEdit() {
if (message.role === MessageRole.SYSTEM) {
// System messages: update in place without branching
const newContent = editedContent.trim();
// If content is empty, remove without deleting children
if (!newContent) {
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
goto(ROUTES.START);
} else {
focusMainChatForm();
}
return;
}
await DatabaseService.updateMessage(message.id, { content: newContent });
const index = conversationsStore.findMessageIndex(message.id);
if (index !== -1) {
conversationsStore.updateMessageAtIndex(index, { content: newContent });
}
focusMainChatForm();
} else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras();
chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else {
// For assistant messages, preserve exact content including trailing whitespace
// This is important for the Continue feature to work properly
chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
}
isEditing = false;
shouldBranchAfterEdit = false;
editedUploadedFiles = [];
}
async function handleSaveEditOnly() {
if (message.role === MessageRole.USER) {
// For user messages, trim to avoid accidental whitespace
const finalExtras = await getMergedExtras();
chatActions.editUserMessagePreserveResponses(message, editedContent.trim(), finalExtras);
}
isEditing = false;
editedUploadedFiles = [];
}
async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
if (editedUploadedFiles.length === 0) {
return editedExtras;
}
const plainFiles = $state.snapshot(editedUploadedFiles);
const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || [];
return [...editedExtras, ...newExtras];
}
function handleShowDeleteDialogChange(show: boolean) {
showDeleteDialog = show;
}
</script>
<div>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem bind:textareaElement class={className} {message} />
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
{:else if isSynthetic}
<ChatMessageSynthetic class={className} {message} />
{:else if message.role === MessageRole.USER}
<ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
{:else}
<ChatMessageAssistant
bind:textareaElement
class={className}
{isLastAssistantMessage}
{message}
onContinue={handleContinue}
onRegenerate={handleRegenerate}
{toolMessages}
/>
{/if}
</div>
@@ -0,0 +1,238 @@
<script lang="ts">
import {
ChatMessageActionIcons,
ChatMessageAgenticContent,
ChatMessageAssistantModel,
ChatMessageAssistantProcessingInfo,
ChatMessageAssistantRawOutput,
ChatMessageAssistantStatistics,
ChatMessageEditForm
} from '$lib/components/app';
import { getChatMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
import { modelLoadProgressText } from '$lib/utils';
import { hasAgenticContent } from '$lib/utils';
interface Props {
class?: string;
isLastAssistantMessage?: boolean;
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
onContinue?: () => void;
onRegenerate: (modelOverride?: string) => void;
textareaElement?: HTMLTextAreaElement;
}
let {
class: className = '',
isLastAssistantMessage = false,
message,
onContinue,
onRegenerate,
textareaElement = $bindable(),
toolMessages = []
}: Props = $props();
// Get edit context
const editCtx = getChatMessageEditContext();
const isAgentic = $derived(hasAgenticContent(message, toolMessages));
const processingState = useProcessingState();
let currentConfig = $derived(settingsStore.config);
let isRouter = $derived(serverStore.isRouterMode);
let showRawOutput = $state(false);
let displayedModel = $derived(message.model ?? null);
let isCurrentlyLoading = $derived(chatStore.isLoading);
let isStreaming = $derived(chatStore.isStreaming());
let hasNoContent = $derived(!message?.content?.trim());
let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming);
// during a router auto-load the message has no model yet: target the model frozen in the
// persisted stream state (survives a reload), then fall back to the dropdown selection
let loadTargetModel = $derived(
message.model ?? chatStore.getResumeModel(message.convId) ?? modelsStore.selectedModelName
);
let modelLoadProgress = $derived(
isRouter && loadTargetModel ? modelsStore.status.getLoadProgress(loadTargetModel) : null
);
let modelLoadingText = $derived(modelLoadProgressText(modelLoadProgress));
let showProcessingInfoTop = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
hasNoContent &&
!isAgentic &&
isLastAssistantMessage
);
let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing &&
(!hasNoContent || isAgentic) &&
isLastAssistantMessage
);
let assistantEl: HTMLDivElement | undefined = $state();
let lastUserMessageHeight = $state(0);
let assistantMarginTop = $state(0);
// The measured CSS vars feed the :last-child min-height rule only, so only
// the last assistant message needs them. Reading isLastAssistantMessage
// here also re-runs the effect when this message stops being the last.
$effect(() => {
if (!assistantEl || !isLastAssistantMessage) return;
assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
const chatMessageEl = assistantEl.closest('.chat-message');
const previousChatMessage = chatMessageEl?.previousElementSibling;
const userMessageEl = previousChatMessage?.querySelector(
'.chat-message-user'
) as HTMLElement | null;
if (!previousChatMessage || !userMessageEl) {
lastUserMessageHeight = 0;
return;
}
// a first exchange opened only by synthetic rows (e.g. the working
// directory notice) must fit the screen as well, so they count as part
// of the user turn; real earlier messages still scroll away above it
const leadingRows: Element[] = [];
for (
let row = previousChatMessage.previousElementSibling;
row;
row = row.previousElementSibling
) {
leadingRows.unshift(row);
}
const leadingRowsAreSynthetic =
leadingRows.length > 0 &&
leadingRows.every((row) => row.classList.contains('chat-message--synthetic'));
const updateHeight = () => {
const rect = userMessageEl.getBoundingClientRect();
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
const leadingHeight = leadingRowsAreSynthetic
? previousChatMessage.getBoundingClientRect().top -
leadingRows[0].getBoundingClientRect().top
: 0;
lastUserMessageHeight = Math.round(rect.height + marginTop + leadingHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(userMessageEl);
if (leadingRowsAreSynthetic) {
for (const row of leadingRows) resizeObserver.observe(row);
}
return () => {
resizeObserver.disconnect();
};
});
$effect(() => {
if (showProcessingInfoTop || showProcessingInfoBottom) {
processingState.startMonitoring();
}
});
</script>
<div
bind:this={assistantEl}
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
style:--last-user-message-height={lastUserMessageHeight > 0
? `${lastUserMessageHeight}px`
: undefined}
aria-label="Assistant message with actions"
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
role="group"
>
{#if showProcessingInfoTop}
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="top" {processingState} />
{/if}
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
{#if showRawOutput}
<ChatMessageAssistantRawOutput {message} {toolMessages} />
{:else}
<ChatMessageAgenticContent
{isLastAssistantMessage}
isStreaming={chatStore.isStreaming()}
{message}
{toolMessages}
/>
{/if}
{/if}
{#if showProcessingInfoBottom}
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="bottom" {processingState} />
{/if}
{#if displayedModel}
<div class="info my-6 grid gap-4 tabular-nums">
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
isLoading={chatStore.isLoading}
{isRouter}
{onRegenerate}
/>
<ChatMessageAssistantStatistics
isLoading={chatStore.isLoading}
{message}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
</div>
</div>
{/if}
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
actionsPosition="left"
justify="start"
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
</div>
<style>
:global(.chat-message):last-child .chat-message-assistant {
--assistant-min-height-offset: calc(
var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
);
min-height: calc(100dvh - var(--assistant-min-height-offset));
@media (width > 768px) {
--assistant-min-height-offset: calc(
var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
);
}
}
</style>
@@ -0,0 +1,41 @@
<script lang="ts">
import { ModelBadge, ModelsSelector } from '$lib/components/app';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import { copyToClipboard } from '$lib/utils';
interface Props {
displayedModel: string | null;
isRouter: boolean;
isLoading: boolean;
onRegenerate: (modelOverride?: string) => void;
}
let { displayedModel, isLoading, isRouter, onRegenerate }: Props = $props();
function handleCopyModel() {
void copyToClipboard(displayedModel ?? '');
}
</script>
{#if isRouter}
<ModelsSelector
currentModel={displayedModel}
disabled={isLoading}
onModelChange={async (modelId: string, modelName: string) => {
const status = modelsStore.getModelStatus(modelId);
// the picker's own selection applies before this resolves, so a model
// that is not loaded yet gets its load request in first
if (status !== ServerModelStatus.LOADED) {
await modelsStore.status.load(modelId);
}
onRegenerate(modelName);
return true;
}}
/>
{:else}
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
import { fade } from 'svelte/transition';
interface Props {
modelLoadingText: string | null;
processingState: UseProcessingStateReturn;
position: 'top' | 'bottom';
}
let { modelLoadingText, position, processingState }: Props = $props();
const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
</script>
<div in:fade class="{marginClass} w-full max-w-3xl">
<div class="flex flex-col items-start gap-2">
<span class="shimmer-text text-sm">
{modelLoadingText ??
processingState.getPromptProgressText() ??
processingState.getProcessingMessage() ??
'Processing...'}
</span>
</div>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { buildAssistantRawOutput, deriveAgenticSections } 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>
@@ -0,0 +1,58 @@
<script lang="ts">
import { ChatMessageStatistics } from '$lib/components/app';
import { ChatMessageStatisticsMode } from '$lib/enums';
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
import { agenticStore } from '$lib/stores';
interface Props {
message: DatabaseMessage;
isLoading: boolean;
processingState: UseProcessingStateReturn;
showMessageStats: boolean;
}
let { isLoading, message, processingState, showMessageStats }: Props = $props();
// A running agentic flow stamps per-turn timings on its root message at each
// turn boundary and the cumulative agentic totals only on exit; while it runs,
// show the session's live totals on the root message instead.
const liveLlm = $derived(agenticStore.getLiveLlmTotals(message.convId));
const isLiveFlowRoot = $derived(
liveLlm !== null && agenticStore.getFlowRootMessageId(message.convId) === message.id
);
</script>
{#if showMessageStats && isLiveFlowRoot && liveLlm}
<ChatMessageStatistics
isLive
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={liveLlm.predicted_ms}
predictedTokens={liveLlm.predicted_n}
promptMs={liveLlm.prompt_ms}
promptTokens={liveLlm.prompt_n}
/>
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
agenticTimings={agentic}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
/>
{:else if isLoading && showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}
{@const genStats = processingState.getLiveGenerationStats()}
{#if genStats}
<ChatMessageStatistics
isLive
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={genStats.timeMs}
predictedTokens={genStats.tokensGenerated}
promptMs={liveStats?.timeMs}
promptTokens={liveStats?.tokensProcessed}
/>
{/if}
{/if}
@@ -0,0 +1,34 @@
<script lang="ts">
import { Folder, FolderX } from '@lucide/svelte';
import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
message: DatabaseMessage;
}
let { class: className = '', message }: Props = $props();
// Parse the synthetic message content in the UI so the row reuses the
// exact same text the model saw, including any guidance suffix.
let info = $derived(parseCwdMessage(message.content));
</script>
{#if info}
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
{#if info.path === null}
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
{:else}
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Set working directory to&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
{info.display}
</span>
{/if}
</div>
{/if}
@@ -0,0 +1,43 @@
<script lang="ts">
import {
ChatMessageActionIcons,
ChatMessageEditForm,
ChatMessageMcpPromptContent
} from '$lib/components/app';
import { getChatMessageEditContext } from '$lib/contexts';
import { McpPromptVariant, MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
message: DatabaseMessage;
mcpPrompt: DatabaseMessageExtraMcpPrompt;
}
let { class: className = '', mcpPrompt, message }: Props = $props();
// Get edit context
const editCtx = getChatMessageEditContext();
</script>
<div
aria-label="MCP Prompt message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
<ChatMessageMcpPromptContent
class="w-full max-w-[80%]"
prompt={mcpPrompt}
variant={McpPromptVariant.MESSAGE}
/>
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,200 @@
<script lang="ts">
import { TruncatedText } from '$lib/components/app/misc';
import { Card } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { McpPromptVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
import { SvelteMap } from 'svelte/reactivity';
interface ContentPart {
text: string;
argKey: string | null;
}
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
variant?: McpPromptVariant;
isLoading?: boolean;
loadError?: string;
}
let {
class: className = '',
isLoading = false,
loadError,
prompt,
variant = McpPromptVariant.MESSAGE
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
return [{ argKey: null, text: prompt.content || '' }];
}
const parts: ContentPart[] = [];
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
}
}
const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
while (remaining.length > 0) {
let earliestMatch: { index: number; value: string; key: string } | null = null;
for (const value of sortedValues) {
const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, key: valueToKey.get(value)!, value };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
parts.push({ argKey: null, text: remaining.slice(0, earliestMatch.index) });
}
parts.push({ argKey: earliestMatch.key, text: earliestMatch.value });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
parts.push({ argKey: null, text: remaining });
break;
}
}
return parts;
});
let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
let maxHeightStyle = $derived(
isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
);
const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
</script>
<div class="flex flex-col gap-2 {className}">
<div class="flex items-center justify-between gap-2">
<div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
<Tooltip.Root>
<Tooltip.Trigger>
{#if serverFavicon}
<img
alt=""
class="h-3.5 w-3.5 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={serverFavicon}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{serverDisplayName}</span>
</Tooltip.Content>
</Tooltip.Root>
<TruncatedText text={prompt.name} />
</div>
{#if showArgBadges}
<div class="flex flex-wrap justify-end gap-1">
{#each argumentEntries as [key, value] (key)}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
hoveredArgKey !== key
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = key)}
onmouseleave={() => (hoveredArgKey = null)}
>
{key}
</span>
</Tooltip.Trigger>
<Tooltip.Content>
<span class="max-w-xs break-all">{value}</span>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{/if}
</div>
{#if loadError}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} text-destructive">{loadError}</span>
</div>
</Card>
{:else if isLoading}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<div class="space-y-2">
<div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
<div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
</div>
</div>
</Card>
{:else if hasContent}
<Card
class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
>
<div
class="overflow-y-auto {paddingClass}"
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
<span class="{textSizeClass} whitespace-pre-wrap">
<!-- This formatting is needed to keep the text in proper shape -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each contentParts as part, i (i)}{#if part.argKey}<span
class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
hoveredArgKey !== part.argKey
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = part.argKey)}
onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
>{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
>{part.text}</span
>{/if}{/each}</span
>
</div>
</Card>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
import type { DatabaseMessage } from '$lib/types';
import { parseCwdMessage } from '$lib/utils';
interface Props {
class?: string;
message: DatabaseMessage;
}
let { class: className = '', message }: Props = $props();
// Synthetic messages render a dedicated UI, never a user bubble. The only
// kind today is the working-directory change; parse the content so the
// row reuses the exact synthetic text (and future kinds slot in here).
let isCwdChange = $derived(parseCwdMessage(message.content) !== null);
</script>
{#if isCwdChange}
<ChatMessageCwdChange class={className} {message} />
{:else}
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
{/if}
@@ -0,0 +1,198 @@
<script lang="ts">
import { Check, X } from '@lucide/svelte';
import { ChatMessageActionIcons, MarkdownContent } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card';
import { INPUT_CLASSES } from '$lib/constants';
import { getChatMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums';
import { settingsStore } from '$lib/stores';
import { autoResizeTextarea, isIMEComposing } from '$lib/utils';
interface Props {
class?: string;
message: DatabaseMessage;
textareaElement?: HTMLTextAreaElement;
}
let { class: className = '', message, textareaElement = $bindable() }: Props = $props();
const editCtx = getChatMessageEditContext();
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
editCtx.save();
} else if (event.key === KeyboardKey.ESCAPE) {
event.preventDefault();
editCtx.cancel();
}
}
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
let isExpanded = $state(false);
let contentHeight = $state(0);
const MAX_HEIGHT = 200; // pixels
const currentConfig = settingsStore.config;
let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
$effect(() => {
if (!messageElement || !message.content.trim()) return;
if (message.content.includes('\n')) {
isMultiline = true;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const element = entry.target as HTMLElement;
const estimatedSingleLineHeight = 24;
isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
contentHeight = element.scrollHeight;
}
});
resizeObserver.observe(messageElement);
return () => {
resizeObserver.disconnect();
};
});
$effect(() => {
if (editCtx.isEditing && textareaElement) {
autoResizeTextarea(textareaElement);
}
});
function toggleExpand() {
isExpanded = !isExpanded;
}
</script>
<div
aria-label="System message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
{#if editCtx.isEditing}
<div class="w-full max-w-[80%]">
<textarea
bind:this={textareaElement}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
editCtx.setContent(e.currentTarget.value);
}}
onkeydown={handleEditKeydown}
placeholder="Edit system message..."
style="max-height: var(--max-message-height);"
value={editCtx.editedContent}
></textarea>
<div class="mt-2 flex justify-end gap-2">
<Button class="h-8 px-3" onclick={editCtx.cancel} size="sm" variant="outline">
<X class="mr-1 h-3 w-3" />
Cancel
</Button>
<Button
class="h-8 px-3"
disabled={!editCtx.editedContent.trim()}
onclick={editCtx.save}
size="sm"
>
<Check class="mr-1 h-3 w-3" />
Save
</Button>
</div>
</div>
{:else}
{#if message.content.trim()}
<div class="relative max-w-[80%]">
<button
class="group/expand w-full text-left {!isExpanded && showExpandButton
? 'cursor-pointer'
: 'cursor-auto'}"
onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
type="button"
>
<Card
class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined}
style="border: 2px dashed hsl(var(--border)); max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
>
<div
class="relative transition-all duration-300 {isExpanded
? 'cursor-text select-text'
: 'select-none'}"
style={!isExpanded && showExpandButton
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
{:else}
<span
bind:this={messageElement}
class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
>
{message.content}
</span>
{/if}
{#if !isExpanded && showExpandButton}
<div
class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
></div>
<div
class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
>
<Button
class="rounded-full px-4 py-1.5 text-xs shadow-md"
size="sm"
variant="outline"
>
Show full system message
</Button>
</div>
{/if}
</div>
{#if isExpanded && showExpandButton}
<div class="mb-2 flex justify-center">
<Button
class="rounded-full px-4 py-1.5 text-xs"
onclick={(e) => {
e.stopPropagation();
toggleExpand();
}}
size="sm"
variant="outline"
>
Collapse System Message
</Button>
</div>
{/if}
</Card>
</button>
</div>
{/if}
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,80 @@
<script lang="ts">
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 ChatMessageToolCallBlockGetInfo from './ChatMessageToolCallBlockGetInfo.svelte';
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
import ChatMessageToolCallBlockReadMedia from './ChatMessageToolCallBlockReadMedia.svelte';
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection, DatabaseMessageExtra } from '$lib/types';
import {
extractSearchQuery,
extractSearchResults,
isWebSearchToolName,
looksLikeSearchResult
} from '$lib/utils';
interface Props {
section: AgenticSection;
attachments?: DatabaseMessageExtra[];
conversationCwd?: string;
open: boolean;
isStreaming: boolean;
isExecuting?: boolean;
onToggle?: () => void;
}
let { attachments, conversationCwd, isExecuting, isStreaming, onToggle, open, section }: Props =
$props();
// Runs for every tool block on mount, before the body renders: the cheap
// content prefilter and the tool-name allow-list come first so blobs from
// exec/file tools are never line-split or JSON-parsed here
const isSearchCall = $derived.by(() => {
if (looksLikeSearchResult(section.toolResult)) {
return extractSearchResults(section.toolResult).length > 0;
}
return isWebSearchToolName(section.toolName) && extractSearchQuery(section.toolArgs).length > 0;
});
</script>
{#if isSearchCall}
<ChatMessageToolCallBlockSearchResults {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
<ChatMessageToolCallBlockGetDatetime {isStreaming} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
<ChatMessageToolCallBlockGetInfo {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
<ChatMessageToolCallBlockReadFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
<ChatMessageToolCallBlockReadMedia {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EDIT_FILE}
<ChatMessageToolCallBlockEditFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_WRITE_FILE}
<ChatMessageToolCallBlockWriteFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
<ChatMessageToolCallBlockExecShellCommand
{attachments}
{conversationCwd}
{isExecuting}
{isStreaming}
{onToggle}
{open}
{section}
/>
{:else if section.toolName === BuiltInTool.SERVER_FILE_GLOB_SEARCH}
<ChatMessageToolCallBlockFileGlobSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GREP_SEARCH}
<ChatMessageToolCallBlockGrepSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_RUN_JAVASCRIPT}
<ChatMessageToolCallBlockRunJavascript {isStreaming} {onToggle} {open} {section} />
{:else}
<ChatMessageToolCallBlockDefault {attachments} {isStreaming} {onToggle} {open} {section} />
{/if}
@@ -0,0 +1,142 @@
<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 ToolCallBlock from './ToolCallBlock.svelte';
import { Loader2 } from '@lucide/svelte';
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { AttachmentType, FileTypeText, MimeTypeAudio, ToolResultKind } from '$lib/enums';
import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types';
import {
classifyToolResult,
formatJsonPretty,
getToolUi,
parseToolResultWithMedia
} from '$lib/utils';
import { createBase64DataUrl } from '$lib/utils/data-url';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
}
let { attachments, isStreaming, onToggle, open, section }: Props = $props();
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
const outputKind = $derived(classifyToolResult(section.toolResult));
const parsedLines: ToolResultLine[] = $derived(
section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
);
</script>
<ToolCallBlock {isStreaming} meta={null} {onToggle} {open} {section} {title}>
{#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 {attachments} content={section.toolResult} />
{: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.media}
{#if line.media.type === AttachmentType.AUDIO}
{@const audioMimeType = line.media.mimeType ?? MimeTypeAudio.MP3_MPEG}
<div class="mt-2 mb-2">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, line.media.base64Data)}
type={audioMimeType}
/>
Your browser does not support the audio element.
</audio>
</div>
{:else}
<img
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/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>
@@ -0,0 +1,203 @@
<script lang="ts">
import { parseEditFileMeta, parseEditFileTitleMeta } from './parsers/edit-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome, computeLineDiff, formatJsonPretty, prefixFor } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const editFileMeta = $derived(parseEditFileTitleMeta(section));
// body-only: the full meta parses the embedded edit strings, and these
// deriveds are read solely from the children snippet, which renders only
// while the block is expanded
const editFileBody = $derived(parseEditFileMeta(section));
const home = $derived(toolsStore.serverHome);
const editDiffs = $derived(
(editFileBody?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
);
</script>
<ToolCallBlock {isStreaming} meta={editFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span class="shrink-0 text-muted-foreground">Edit file</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="min-w-0 overflow-x-auto font-mono" title={editFileMeta?.filePath}>
{abbreviateHome(editFileMeta?.filePath ?? '', home)}
</span>
{#if editFileMeta?.errorMessage}
<span class="shrink-0 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
</span>
</span>
{/snippet}
{#snippet children(_meta, ctx)}
{#if editFileBody?.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>{editFileBody.errorMessage}</span>
</div>
{:else if editFileBody?.edits.length}
{#each editDiffs as diffLines, ei (ei)}
<div class={ei === 0 ? '' : 'mt-3'}>
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
Edit {ei + 1}&nbsp;of&nbsp;{editFileBody.edits.length}
</div>
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-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 editFileBody.resultMessage}
{editFileBody.resultMessage}{editFileBody.editsApplied != null
? RESULT_STAT_SEPARATOR
: ''}{/if}
{#if editFileBody.editsApplied != null}
<span class="font-mono">{editFileBody.editsApplied}</span>
{editFileBody.editsApplied === 1 ? 'edit' : 'edits'}&nbsp;applied
{/if}
</div>
{:else if section.toolArgs}
<!-- no usable edits: not streamed yet, or a same-named tool with other arg names -->
<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-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>
@@ -0,0 +1,395 @@
<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 { parseExecShellCommandMeta } from './parsers/exec-shell-command';
import ToolCallBlock from './ToolCallBlock.svelte';
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AgenticSectionType, AttachmentType } from '$lib/enums';
import { settingsStore, toolsStore } from '$lib/stores';
import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types';
import {
abbreviateHome,
type ExecShellExitStatus,
highlightCode,
isExitCodeSummaryLine,
parseExecShellCommandError,
parseExecShellCommandExitStatus,
parseToolResultWithMedia
} from '$lib/utils';
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;
/** Working directory of the conversation this call belongs to; stands
* in for `section.toolCwd` until the result message persists the
* real one. */
conversationCwd?: string;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
}
let {
attachments,
conversationCwd,
isExecuting = false,
isStreaming,
onToggle,
open,
section
}: 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 isPreResult = $derived(
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
section.type === AgenticSectionType.TOOL_CALL_STREAMING
);
// The model is still typing the call while the args JSON doesn't parse;
// the title keeps a terminal cursor until it does.
const commandStreaming = $derived(
isStreaming && isPreResult && !isParsableJson(section.toolArgs)
);
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 ? parseToolResultWithMedia(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') : ''
);
// The working directory the command ran with, persisted per call on the
// tool result message (it travels via the x-tool-cwd header, not the tool
// args). Until that message exists, the conversation cwd is the best known
// value - it is what the agentic loop passes when executing the call.
const cwd = $derived(
section.toolCwd ?? (isStreaming && isPreResult ? conversationCwd : undefined)
);
const home = $derived(toolsStore.serverHome);
const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
const exitBadgeClass = $derived(
execShellExitStatus?.timedOut
? 'exit-badge warning'
: execShellExitStatus?.code === 0
? 'exit-badge success'
: 'exit-badge failure'
);
const useFullHeightCodeBlocks = $derived(
Boolean(settingsStore.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 isParsableJson(value: string | undefined): boolean {
if (!value) return false;
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}
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, {
characterData: true,
childList: true,
subtree: 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 cwd}
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
<span class="exec-prompt">$</span>
{/if}
{#if highlightedCommandHtml}
<span class="font-mono">{@html highlightedCommandHtml}</span>
{:else}
<span class="font-mono">{execShellMeta?.command}</span>
{/if}
{#if commandStreaming}
<span class="exec-cursor"></span>
{/if}
{/snippet}
<ToolCallBlock
extraLiveStreaming={isLive}
{isStreaming}
meta={execShellMeta ? { errorMessage: execShellError } : null}
{onToggle}
{open}
{section}
spinIconWhenActive={true}
wrapper={CollapsibleTerminalBlock}
>
{#snippet titleSnippet()}
{@render execShellTitle()}
{/snippet}
{#snippet children(_meta, ctx)}
{#if (ctx.isPending && !commandStreaming) || (isLive && !section.toolResult)}
<div class="flex items-start gap-2 text-xs text-muted-foreground/70">
<Loader2 class="h-3 w-3 animate-spin" />
Executing command...
</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:is-clamped={!useFullHeightCodeBlocks}
class="terminal-output"
onscroll={handleScrollEvent}
>
{#each outputLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.media?.type === AttachmentType.IMAGE}
<img
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/each}
{#if isExitCodeFinalLine && execShellExitStatus}
<div class={exitBadgeClass}>
{#if execShellExitStatus.timedOut}
<AlertTriangle class="h-3 w-3" />
<span>timed out</span>
<span class="exit-sep">&middot;</span>
<span>exit {execShellExitStatus.code}</span>
{:else if execShellExitStatus.code === 0}
<Check class="h-3 w-3" />
<span>exit 0</span>
{:else}
<XCircle class="h-3 w-3" />
<span>exit {execShellExitStatus.code}</span>
{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
</ToolCallBlock>
<style>
:root {
--exec-wd-margin: 0.4rem;
}
.exec-wd {
font-family: var(--font-mono);
color: var(--muted-foreground);
margin-right: var(--exec-wd-margin);
}
.exec-prompt {
font-family: var(--font-mono);
color: var(--muted-foreground);
opacity: 0.55;
margin-right: var(--exec-wd-margin);
}
.exec-cursor {
display: inline-block;
width: 0.5em;
height: 1.05em;
margin-left: 0.15rem;
vertical-align: text-bottom;
background: var(--foreground);
animation: exec-cursor-blink 1.1s steps(1) infinite;
}
@keyframes exec-cursor-blink {
50% {
opacity: 0;
}
}
.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>
@@ -0,0 +1,72 @@
<script lang="ts">
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {isStreaming} meta={fileGlobMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if fileGlobMeta}
<span class="text-muted-foreground"
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'}&nbsp;</span
>
{#if fileGlobMeta.include !== '**'}
<span class="font-mono">{fileGlobMeta.include}</span>
{/if}
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={fileGlobMeta.path}
>{abbreviateHome(fileGlobMeta.path, home)}</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>
@@ -0,0 +1,64 @@
<script lang="ts">
import { Clock, Loader2 } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
interface Props {
section: AgenticSection;
isStreaming?: boolean;
}
let { isStreaming = false, section }: 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 {
// not JSON - nothing to show
}
return {};
}
const dateMeta = $derived(parseGetDatetimeMeta(section.toolResult));
</script>
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if dateMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Current time&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{dateMeta.errorMessage}</span
>
{:else if dateMeta.dateString}
<span class="text-foreground/80 text-sm font-medium">Current time is&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
{:else}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
{/if}
</div>
@@ -0,0 +1,126 @@
<script lang="ts">
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
type GetInfoMeta = {
os?: string;
cwd?: string;
errorMessage?: string;
};
function parseGetInfoMeta(toolResultString: string | undefined): GetInfoMeta {
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 };
return {
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
os: typeof obj.os === 'string' ? obj.os : undefined
};
}
} catch {
// not JSON - nothing to show
}
return {};
}
const infoMeta = $derived(parseGetInfoMeta(section.toolResult));
const home = $derived(toolsStore.serverHome);
const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
</script>
<ToolCallBlock
{isStreaming}
meta={infoMeta}
{onToggle}
{open}
{section}
spinIconWhenActive
title="Runtime info"
>
{#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 infoMeta.os || infoMeta.cwd}
<table class="w-full table-fixed border-collapse text-sm">
<colgroup>
<col class="w-12" />
<col />
</colgroup>
<tbody class="divide-y divide-border/50">
{#if infoMeta.os}
<tr>
<th
class="py-1 pr-3 text-left align-baseline text-[11px] font-medium tracking-wide text-muted-foreground/60 uppercase"
scope="row"
>
os
</th>
<td class="py-1 align-baseline">
<div class="min-w-0 overflow-x-auto font-mono text-foreground/90">
{infoMeta.os}
</div>
</td>
</tr>
{/if}
{#if infoMeta.cwd}
<tr>
<th
class="py-1 pr-3 text-left align-baseline text-[11px] font-medium tracking-wide text-muted-foreground/60 uppercase"
scope="row"
>
cwd
</th>
<td class="py-1 align-baseline">
<div
class="min-w-0 overflow-x-auto font-mono text-foreground/90"
title={infoMeta.cwd}
>
{cwdDisplay}
</div>
</td>
</tr>
{/if}
</tbody>
</table>
{:else if section.toolResult}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
{section.toolResult}
</div>
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Waiting for runtime info...
</div>
{/if}
{/snippet}
</ToolCallBlock>
@@ -0,0 +1,80 @@
<script lang="ts">
import { parseGrepSearchMeta } from './parsers/grep-search';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const grepMeta = $derived(parseGrepSearchMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {isStreaming} meta={grepMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if grepMeta}
<span class="text-muted-foreground">Search for&nbsp;</span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
{#snippet children(meta, ctx)}
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Searching...
</div>
{:else if meta?.errorMessage}
<div
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.matches.length > 0}
<div class="max-h-96 overflow-auto">
{#each meta.matches as match, mi (mi)}
<div class="font-mono text-[11px] leading-relaxed">
<span class="text-muted-foreground/70">{match.file}</span>
{#if meta.showLineNumbers && match.line != null}
<span class="text-muted-foreground/70">:{match.line}</span>
{/if}
<span class="text-muted-foreground/70">:</span>
<span>{match.content}</span>
</div>
{/each}
</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
{#if meta.showLineNumbers}
&nbsp;<span class="italic">(with line numbers)</span>
{/if}
</div>
{:else}
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
</div>
{/if}
{/snippet}
</ToolCallBlock>
@@ -0,0 +1,50 @@
<script lang="ts">
import { parseReadFileMeta } from './parsers/read-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { CODE_BLOCK, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import type { AgenticSection } from '$lib/types';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const readFileMeta = $derived(parseReadFileMeta(section));
</script>
<ToolCallBlock {isStreaming} meta={readFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span class="shrink-0 text-muted-foreground">Read file</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="min-w-0 overflow-x-auto font-mono">{readFileMeta?.fileName}</span>
{#if readFileMeta?.lineRange}
<span class="shrink-0 text-muted-foreground">
(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})
</span>
{/if}
</span>
</span>
{/snippet}
{#snippet children(_meta, _ctx)}
{#if section.toolResult}
<SyntaxHighlightedCode
code={section.toolResult}
language={readFileMeta?.language ?? CODE_BLOCK.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>
@@ -0,0 +1,103 @@
<script lang="ts">
import { parseReadMediaMeta } from './parsers/read-media';
import ToolCallBlock from './ToolCallBlock.svelte';
import { ATTACHMENT_SAVED_REGEX } from '$lib/constants/agentic.constants';
import { AttachmentType, MimeTypeAudio } from '$lib/enums';
import type { DatabaseMessageExtraAudioFile, DatabaseMessageExtraImageFile } from '$lib/types';
import type { AgenticSection } from '$lib/types';
import { createBase64DataUrl } from '$lib/utils/data-url';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const readMediaMeta = $derived(parseReadMediaMeta(section));
// extractBase64Attachments swapped the data URI line for [Attachment saved: name]
// and moved the bytes to the message extras, so the name is the only link back
const mediaAttachment = $derived.by(() => {
const extras = section.toolResultExtras;
if (!extras || extras.length === 0) return null;
const match = section.toolResult?.match(ATTACHMENT_SAVED_REGEX);
if (!match) return null;
const attachmentName = match[1];
return (
extras.find(
(e): e is DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile =>
(e.type === AttachmentType.IMAGE || e.type === AttachmentType.AUDIO) &&
e.name === attachmentName
) ?? null
);
});
const audioMimeType = $derived(readMediaMeta?.mimeType ?? MimeTypeAudio.MP3_MPEG);
</script>
<ToolCallBlock {isStreaming} meta={readMediaMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span class="shrink-0 text-muted-foreground">Read media</span>
<span class="min-w-0 overflow-x-auto font-mono">{readMediaMeta?.fileName}</span>
</span>
{/snippet}
{#snippet children(_meta, _ctx)}
{#if section.toolResult}
{#if !mediaAttachment}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Media attachment not found in message extras
</div>
{:else if mediaAttachment.type === AttachmentType.AUDIO}
<div class="mt-2">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, mediaAttachment.base64Data)}
type={audioMimeType}
/>
Your browser does not support the audio element.
</audio>
</div>
{:else}
<div class="mt-2">
<img
alt={readMediaMeta?.fileName ?? 'media'}
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
src={mediaAttachment.base64Url}
/>
</div>
{/if}
{#if readMediaMeta?.sizeBytes || readMediaMeta?.mimeType}
<div class="mt-2 flex gap-4 text-xs text-muted-foreground">
{#if readMediaMeta?.sizeBytes}
<span>Size: {readMediaMeta.sizeBytes} bytes</span>
{/if}
{#if readMediaMeta?.mimeType}
<span>MIME: {readMediaMeta.mimeType}</span>
{/if}
</div>
{/if}
{#if readMediaMeta?.path}
<div class="mt-1 font-mono text-xs text-muted-foreground/60">{readMediaMeta.path}</div>
{/if}
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Waiting for media data...
</div>
{/if}
{/snippet}
</ToolCallBlock>
@@ -0,0 +1,76 @@
<script lang="ts">
import { parseRunJavascriptMeta } from './parsers/run-javascript';
import ToolCallBlock from './ToolCallBlock.svelte';
import { Terminal, XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
import { getToolUi } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const runJsMeta = $derived(parseRunJavascriptMeta(section));
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
</script>
<ToolCallBlock {isStreaming} meta={runJsMeta} {onToggle} {open} {section} {title}>
{#snippet children(meta, ctx)}
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
{:else if meta?.errorMessage}
<div
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
<div class="mt-3">
<SyntaxHighlightedCode
code={meta.code}
language={FileTypeText.JAVASCRIPT}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
</div>
{:else if meta}
<SyntaxHighlightedCode
code={meta.code}
language={FileTypeText.JAVASCRIPT}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
<Terminal class="h-3 w-3" />
<span>Console</span>
{#if meta.timeoutMs != null}
<span class="font-mono">&middot;&nbsp;timeout&nbsp;{meta.timeoutMs}&nbsp;ms</span>
{/if}
</div>
{#if section.toolResult}
<div class="mt-1">
<SyntaxHighlightedCode
code={section.toolResult}
language={FileTypeText.JAVASCRIPT}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
/>
</div>
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
{/if}
{/if}
{/snippet}
</ToolCallBlock>
@@ -0,0 +1,173 @@
<script lang="ts">
import { Globe, Loader2 } from '@lucide/svelte';
import { CollapsibleContentBlock } from '$lib/components/app';
import * as HoverCard from '$lib/components/ui/hover-card';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores';
import type { AgenticSection, SearchResult } from '$lib/types';
import {
buildProxiedIconUrl,
extractSearchQuery,
extractSearchResults,
faviconForUrl,
sanitizeExternalUrl
} from '$lib/utils';
interface Props {
section: AgenticSection;
open?: boolean;
isStreaming?: boolean;
onToggle?: () => void;
}
let { isStreaming = false, onToggle, open = $bindable(false), section }: 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 = $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, {
day: 'numeric',
month: 'short',
year: '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 = buildProxiedIconUrl(faviconForUrl(result.url), mcpStore.isProxyAvailable)}
{@const safeUrl = sanitizeExternalUrl(result.url)}
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
{#if safeUrl}
<HoverCard.Root closeDelay={100} openDelay={150}>
<HoverCard.Trigger
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"
href={safeUrl}
rel="noopener noreferrer"
target="_blank"
>
{#if faviconUrl}
<img
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={hideBrokenIcon}
src={faviconUrl}
/>
{: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 align="start" class="w-80 max-w-[90vw]" side="top" sideOffset={6}>
<div class="flex flex-col gap-2 px-2 py-1.5">
<a
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
href={safeUrl}
rel="noopener noreferrer"
target="_blank">{result.title}</a
>
{#if publishDate || result.author}
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
{#if publishDate}
<span>{publishDate}</span>
{/if}
{#if publishDate && result.author}
<span class="opacity-50">&middot;</span>
{/if}
{#if result.author}
<span class="truncate">{result.author}</span>
{/if}
</div>
{/if}
{#if result.highlights}
<p
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
>
{result.highlights}
</p>
{/if}
{#if host}
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
{/if}
</div>
</HoverCard.Content>
{/if}
</HoverCard.Root>
{/if}
{/snippet}
<CollapsibleContentBlock class="my-2" {icon} {iconClass} {iconUrl} {onToggle} {open} {title}>
{#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>
@@ -0,0 +1,90 @@
<script lang="ts">
import { parseWriteFileMeta, parseWriteFileTitleMeta } from './parsers/write-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
import { FileTypeText } from '$lib/enums';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import { abbreviateHome, formatJsonPretty } from '$lib/utils';
interface Props {
section: AgenticSection;
open: boolean;
isStreaming: boolean;
onToggle?: () => void;
}
let { isStreaming, onToggle, open, section }: Props = $props();
const writeFileMeta = $derived(parseWriteFileTitleMeta(section));
// body-only: the full meta parses the embedded file content, and this
// derived is read solely from the children snippet, which renders only
// while the block is expanded
const writeFileBody = $derived(parseWriteFileMeta(section));
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {isStreaming} meta={writeFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="flex min-w-0 flex-wrap items-baseline gap-x-1">
<span class="shrink-0 text-muted-foreground">Write file</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<span class="min-w-0 overflow-x-auto font-mono" title={writeFileMeta?.filePath}>
{abbreviateHome(writeFileMeta?.filePath ?? '', home)}
</span>
{#if writeFileMeta?.errorMessage}
<span class="shrink-0 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
</span>
</span>
{/snippet}
{#snippet children(_meta, ctx)}
{#if writeFileBody?.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>{writeFileBody.errorMessage}</span>
</div>
{:else if writeFileBody?.content !== undefined}
<SyntaxHighlightedCode
code={writeFileBody.content}
language={writeFileBody.language}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
{#if writeFileBody.resultMessage}
{writeFileBody.resultMessage}{writeFileBody.bytesWritten != null
? RESULT_STAT_SEPARATOR
: ''}{/if}
{#if writeFileBody.bytesWritten != null}
<span class="font-mono">{writeFileBody.bytesWritten}</span>
bytes
{/if}
</div>
{:else if section.toolArgs}
<!-- no content arg: not streamed yet, or a same-named tool with other arg names -->
<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-muted/20 p-2 text-xs text-muted-foreground/70 italic">No content</div>
{/if}
{/snippet}
</ToolCallBlock>
@@ -0,0 +1,133 @@
<script generics="TMeta" lang="ts">
// 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';
import { AgenticSectionType } from '$lib/enums';
import { mcpStore } from '$lib/stores';
import type { AgenticSection, ToolUiEntry } from '$lib/types';
import { getToolUi } from '$lib/utils';
import type { Component, Snippet } from 'svelte';
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 {
children,
extraLiveStreaming = false,
isStreaming,
meta,
onToggle,
open,
section,
spinIconWhenActive = false,
title,
titleSnippet,
wrapper: Wrapper = CollapsibleContentBlock
}: 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: ToolUiEntry | null = $derived(getToolUi(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
);
// No subtitle while the call is in flight - the spinner already
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (showSpinner) return undefined;
if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined;
}
const subtitle = $derived(subtitleFor(meta?.errorMessage));
</script>
<Wrapper
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
{iconUrl}
{onToggle}
{open}
{subtitle}
{title}
{titleSnippet}
>
{@render children(meta, {
isCodeStreaming,
isPending,
isStreaming,
isStreamingCall
})}
</Wrapper>
@@ -0,0 +1,124 @@
// 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), plus the same path aliases - keeping
// them here lets each parser stay focused on its own format quirks.
import {
FILE_PATH_SEPARATOR_REGEX,
TOOL_ARG_PATH_KEYS,
TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE
} from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types/agentic';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
/**
* 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;
}
}
// Compiled per key on first use; the key set is tiny and fixed.
const toolArgStringRegexes = new Map<string, RegExp>();
/**
* Extract a string field from a JSON tool-args blob without parsing the
* whole document. write_file and edit_file args embed full file contents,
* yet the block title needs only the path; a targeted key match plus a
* JSON.parse of the captured string literal alone keeps title rendering
* O(path) instead of O(blob). Returns undefined when the key is missing
* or its value is not a string; callers fall back to the full parse.
*/
export function extractToolArgString(
toolArgs: string,
keys: readonly string[]
): string | undefined {
for (const key of keys) {
let pattern = toolArgStringRegexes.get(key);
if (!pattern) {
pattern = new RegExp(TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE.replace('{key}', key));
toolArgStringRegexes.set(key, pattern);
}
const match = pattern.exec(toolArgs);
if (!match) continue;
try {
const value: unknown = JSON.parse(`"${match[1]}"`);
if (typeof value === 'string') return value;
} catch {
// fall through to the next key; the full parse is the fallback
}
}
return undefined;
}
/**
* File name shown in a block title, from a tool-call path. Undefined when
* the call carries no path; the title then shows the tool label alone.
*/
export function fileNameFromPath(filePath: string | undefined): string | undefined {
if (!filePath) return undefined;
return filePath.split(FILE_PATH_SEPARATOR_REGEX).pop() || filePath;
}
/**
* Read the path argument out of an already parsed args object, checking
* the aliases in `TOOL_ARG_PATH_KEYS`. Returns undefined when the call
* carries no usable path - the path is display-only, so callers must
* still render whatever else the call streamed.
*/
export function extractToolArgPath(args?: Record<string, unknown> | null): string | undefined {
if (!args) return undefined;
for (const key of TOOL_ARG_PATH_KEYS) {
const value = args[key];
if (typeof value === 'string' && value) return value;
}
return undefined;
}
/**
* 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);
}
@@ -0,0 +1,109 @@
// 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. The path is display-only (block title), so a call
// that has not streamed one still shows its diffs.
import {
extractToolArgPath,
extractToolArgString,
fileNameFromPath,
parseToolArgs
} from './_shared';
import { TOOL_ARG_PATH_KEYS } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection, EditFileEdit, EditFileMeta, EditFileTitleMeta } from '$lib/types';
import { tryParseToolResultObject } from '$lib/utils';
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
if (!args) return null;
const filePath = extractToolArgPath(args);
const fileName = fileNameFromPath(filePath);
// 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({ newText, oldText });
}
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 {
edits,
editsApplied,
errorMessage,
fileName,
filePath,
resultMessage
};
}
/**
* Title-tier meta for edit_file blocks: everything the header and status
* pill render, obtained without parsing the embedded edit strings. The path
* comes from a targeted key extraction; the full parse runs only as a
* fallback for arg shapes the extraction can't see.
*/
export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null {
if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null;
let filePath = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
if (!filePath) {
filePath = extractToolArgPath(
parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true })
);
}
const fileName = fileNameFromPath(filePath);
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 { editsApplied, errorMessage, fileName, filePath, resultMessage };
}
@@ -0,0 +1,28 @@
// Meta parser for `exec_shell_command` tool calls. Surfaces the
// command text from args `command` / `cmd` / `shell_command` aliases.
// Args are parsed partially so the title can render the command while
// it is still streaming.
// 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 { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
export type ExecShellCommandMeta = {
command: string;
};
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
const args = parseToolArgs(BuiltInTool.SERVER_EXEC_SHELL_COMMAND, section, { partial: true });
if (!args) return null;
const commandRaw = args.command ?? args.cmd ?? args.shell_command;
if (typeof commandRaw !== 'string' || !commandRaw) return null;
return { command: commandRaw };
}
@@ -0,0 +1,66 @@
// 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 { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
import { splitSearchSummaryList } from '$lib/utils';
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.SERVER_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 { errorMessage, exclude, include, matches, path, totalMatches };
}
@@ -0,0 +1,121 @@
// 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 { parseToolArgs } from './_shared';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
import { splitSearchSummaryList } from '$lib/utils';
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.SERVER_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 {
errorMessage,
exclude,
include,
matches,
path,
pattern,
showLineNumbers,
totalMatches
};
}
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 { content: '', file: line };
}
const file = line.slice(0, firstColon);
const tail = line.slice(firstColon + 1);
if (!showLineNumbers) {
return { content: tail, file };
}
const secondColon = tail.indexOf(':');
if (secondColon === -1) {
return { content: tail, file };
}
const lineNum = parseInt(tail.slice(0, secondColon), 10);
return {
content: tail.slice(secondColon + 1),
file,
line: Number.isFinite(lineNum) ? lineNum : undefined
};
}
@@ -0,0 +1,55 @@
// 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 { parseToolArgs } from './_shared';
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
import { getFileTypeByExtension } from '$lib/utils';
export type ReadFileMeta = {
fileName: string;
lineRange: { start: number; end: number } | null;
language: string;
};
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
const args = parseToolArgs(BuiltInTool.SERVER_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 = { end: eNum, start: sNum };
} else if (startRaw != null && countRaw != null) {
const cNum = Number(countRaw);
if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
lineRange = { end: sNum + cNum - 1, start: sNum };
}
}
const fileType = getFileTypeByExtension(fileName);
const language = fileType
? fileType.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '')
: CODE_BLOCK.DEFAULT_LANGUAGE;
return { fileName, language, lineRange };
}
@@ -0,0 +1,56 @@
import { FILE_PATH_SEPARATOR_REGEX, NEWLINE } from '$lib/constants';
import {
PREFIX_FILE,
PREFIX_MIME,
PREFIX_SIZE,
READ_MEDIA_SIZE_REGEX
} from '$lib/constants/read-media';
import type { AgenticSection } from '$lib/types';
export interface ReadMediaMeta {
fileName: string;
path: string;
sizeBytes?: number;
mimeType?: string;
}
/**
* Parse read_media tool result to extract metadata.
* Expected format (after extractBase64Attachments processing):
* File: /path/to/file.png
* Size: 12345 bytes
* MIME: image/png
* [Attachment saved: mcp-attachment-xxx.png]
*
* The data URI line is replaced by the attachment marker by
* agenticStore.extractBase64Attachments before storage.
*/
export function parseReadMediaMeta(section: AgenticSection): ReadMediaMeta | null {
if (!section.toolResult) return null;
const lines = section.toolResult.split(NEWLINE);
let fileName = '';
let path = '';
let sizeBytes: number | undefined;
let mimeType: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith(PREFIX_FILE)) {
path = trimmed.slice(PREFIX_FILE.length).trim();
fileName = path.split(FILE_PATH_SEPARATOR_REGEX).pop() ?? path;
} else if (trimmed.startsWith(PREFIX_SIZE)) {
const match = trimmed.match(READ_MEDIA_SIZE_REGEX);
if (match) sizeBytes = Number(match[1]);
} else if (trimmed.startsWith(PREFIX_MIME)) {
mimeType = trimmed.slice(PREFIX_MIME.length).trim();
}
}
if (!path) return null;
return { fileName, mimeType, path, sizeBytes };
}
@@ -0,0 +1,72 @@
// 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 { parseToolArgs } from './_shared';
import { JSON_ARRAY_OPEN, JSON_OBJECT_OPEN } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types';
export type RunJavascriptMeta = {
code: string;
timeoutMs?: number;
errorMessage?: string;
};
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
const args = parseToolArgs(BuiltInTool.BROWSER_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;
// Successful sandbox output is a JSON array, errors are objects; plain
// text (huge console logs) fails the parse below anyway, so only try
// when the blob starts with a JSON container
const trimmedResult = toolResultString.trimStart();
if (trimmedResult[0] === JSON_OBJECT_OPEN || trimmedResult[0] === JSON_ARRAY_OPEN) {
try {
const parsed: unknown = JSON.parse(trimmedResult);
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, errorMessage, timeoutMs };
}
@@ -0,0 +1,86 @@
// 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. The path is display-only (title + highlight language),
// so a call that has not streamed one still shows its content.
import {
extractToolArgPath,
extractToolArgString,
fileNameFromPath,
parseToolArgs
} from './_shared';
import { CODE_BLOCK, TOOL_ARG_PATH_KEYS } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection, WriteFileMeta, WriteFileTitleMeta } from '$lib/types';
import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
// Plain text when the call carries no path, or one with no known extension.
function languageForPath(filePath: string | undefined): string {
if (!filePath) return CODE_BLOCK.DEFAULT_LANGUAGE;
return (
getFileTypeByExtension(filePath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
CODE_BLOCK.DEFAULT_LANGUAGE
);
}
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
if (!args) return null;
const filePath = extractToolArgPath(args);
const fileName = fileNameFromPath(filePath);
// undefined, not '', so an intentionally empty write still renders
const content = typeof args.content === 'string' ? args.content : undefined;
const language = languageForPath(filePath);
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 {
bytesWritten,
content,
errorMessage,
fileName,
filePath,
language,
resultMessage
};
}
/**
* Title-tier meta for write_file blocks: everything the header and status
* pill render, obtained without parsing the embedded file content. The path
* comes from a targeted key extraction; the full parse runs only as a
* fallback for arg shapes the extraction can't see.
*/
export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null {
if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null;
let filePath = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
if (!filePath) {
filePath = extractToolArgPath(
parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true })
);
}
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 {
bytesWritten,
errorMessage,
fileName: fileNameFromPath(filePath),
filePath,
language: languageForPath(filePath),
resultMessage
};
}
Loaded 100 of 3918 files, more files were not shown because too many files have changed in this diff. Show more