]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
refactor: Naming (#27001)
authorAleksander Grygier <redacted>
Thu, 13 Aug 2026 17:45:32 +0000 (19:45 +0200)
committerGitHub <redacted>
Thu, 13 Aug 2026 17:45:32 +0000 (19:45 +0200)
44 files changed:
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte [deleted file]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte [deleted file]
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/enums/chat.enums.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/types/chat-form-input-rich.d.ts [new file with mode: 0644]
tools/ui/src/lib/types/contenteditable.d.ts [deleted file]
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts [new file with mode: 0644]
tools/ui/src/lib/utils/contenteditable-tokenizer.ts [deleted file]
tools/ui/src/lib/utils/index.ts
tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts [deleted file]
tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts [deleted file]
tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts [deleted file]
tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts
tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/chat-form-input-rich.svelte.test.ts [new file with mode: 0644]
tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts
tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte [deleted file]
tools/ui/tests/client/components/ChatFormInputRichHarness.svelte [new file with mode: 0644]
tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts [new file with mode: 0644]
tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts [new file with mode: 0644]
tools/ui/tests/unit/contenteditable-tokenizer.test.ts [deleted file]
tools/ui/tests/unit/contenteditable-word-jump.test.ts [deleted file]

diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte
deleted file mode 100644 (file)
index 0efe052..0000000
+++ /dev/null
@@ -1,213 +0,0 @@
-<script lang="ts">
-       import {
-               ChatAttachmentsPreviewCurrentItem,
-               ChatAttachmentsPreviewFileInfo,
-               ChatAttachmentsPreviewNavButtons,
-               ChatAttachmentsPreviewThumbnailStrip
-       } from '$lib/components/app';
-       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(`[data-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.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 onPrev={prev} onNext={next} 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
-                                       {currentItem}
-                                       {isImage}
-                                       {isAudio}
-                                       {isVideo}
-                                       {isPdf}
-                                       {isText}
-                                       {displayPreview}
-                                       {displayTextContent}
-                                       {audioSrc}
-                                       {videoSrc}
-                                       {language}
-                                       {hasVisionModality}
-                                       {activeModelId}
-                               />
-                       {/if}
-
-                       <ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
-               </div>
-       </div>
-</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte
new file mode 100644 (file)
index 0000000..0efe052
--- /dev/null
@@ -0,0 +1,213 @@
+<script lang="ts">
+       import {
+               ChatAttachmentsPreviewCurrentItem,
+               ChatAttachmentsPreviewFileInfo,
+               ChatAttachmentsPreviewNavButtons,
+               ChatAttachmentsPreviewThumbnailStrip
+       } from '$lib/components/app';
+       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(`[data-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.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 onPrev={prev} onNext={next} 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
+                                       {currentItem}
+                                       {isImage}
+                                       {isAudio}
+                                       {isVideo}
+                                       {isPdf}
+                                       {isText}
+                                       {displayPreview}
+                                       {displayTextContent}
+                                       {audioSrc}
+                                       {videoSrc}
+                                       {language}
+                                       {hasVisionModality}
+                                       {activeModelId}
+                               />
+                       {/if}
+
+                       <ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
+               </div>
+       </div>
+</div>
index bf56e91083dbada2b8eae1bcf4d65fd08f4d4cd5..49c3dfe205fabef36663557c3d38f696cb3242a6 100644 (file)
@@ -3,12 +3,11 @@
        import {
                ChatAttachmentsList,
                ChatFormActions,
-               ChatFormContentEditable,
-               ChatFormFileInputInvisible,
+               ChatFormCurrentWorkingDirectory,
+               ChatFormInput,
+               ChatFormInputFileInputInvisible,
                ChatFormMcpResourcesList,
                ChatFormPickers,
-               ChatFormTextarea,
-               ChatFormWorkingDirectory,
                DialogMcpResourcesBrowser
        } from '$lib/components/app';
        import {
 
        let audioRecorder: AudioRecorder | undefined;
        let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
-       let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
+       let fileInputRef: ChatFormInputFileInputInvisible | undefined = $state(undefined);
        let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
                $state(undefined);
        let inputRef: ChatInputHandle | undefined = $state(undefined);
        }
 </script>
 
-<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
+<ChatFormInputFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
 
 <form
        class="relative grid {className}"
                <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!"
                >
-                       {#if useContenteditable}
-                               <ChatFormContentEditable
-                                       class="px-5 py-1.5 md:pt-0 mb-0.5"
-                                       bind:this={inputRef}
-                                       bind:value
-                                       onKeydown={handleKeydown}
-                                       onInput={() => {
-                                               pickers.handleInput();
-                                               onValueChange?.(value);
-                                       }}
-                                       onPaste={handlePaste}
-                                       {disabled}
-                                       {placeholder}
-                               />
-                       {:else}
-                               <ChatFormTextarea
-                                       class="px-5 py-1.5 md:pt-0"
-                                       bind:this={inputRef}
-                                       bind:value
-                                       onKeydown={handleKeydown}
-                                       onInput={() => {
-                                               pickers.handleInput();
-                                               onValueChange?.(value);
-                                       }}
-                                       onPaste={handlePaste}
-                                       {disabled}
-                                       {placeholder}
-                               />
-                       {/if}
+                       <ChatFormInput
+                               class="px-5 py-1.5 md:pt-0"
+                               bind:this={inputRef}
+                               bind:value
+                               onKeydown={handleKeydown}
+                               onInput={() => {
+                                       pickers.handleInput();
+                                       onValueChange?.(value);
+                               }}
+                               onPaste={handlePaste}
+                               {disabled}
+                               {placeholder}
+                               {useContenteditable}
+                       />
 
                        {#if mcpResourceStore.hasAttachments}
                                <ChatFormMcpResourcesList
        <ContextGaugePopup />
 
        {#if toolsStore.hasEnabledCwdTools}
-               <ChatFormWorkingDirectory
+               <ChatFormCurrentWorkingDirectory
                        directory={cwd}
                        isOpen={pickers.isWorkingDirectoryPickerOpen}
                        bind:query={pickers.workingDirectoryQuery}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte
deleted file mode 100644 (file)
index ba44304..0000000
+++ /dev/null
@@ -1,849 +0,0 @@
-<script lang="ts">
-       import { CODE_BLOCK } from '$lib/constants';
-       import { ColorMode } from '$lib/enums';
-       import { isMobile } from '$lib/stores';
-       import type { ContentEditableToken } 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 contenteditable 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 ? 'true' : 'false';
-       }
-
-       function renderTokens(tokens: ContentEditableToken[]) {
-               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 contenteditable 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[data-code-token="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.dataset.codeToken === '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[data-highlight-theme-preview]').forEach((s) => s.remove());
-
-               const style = document.createElement('style');
-
-               style.setAttribute('data-highlight-theme-preview', '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 contenteditable 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.dataset.codeToken === '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.dataset.codeToken !== '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 contenteditable 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.dataset.codeToken !== '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;
-                       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 (!isMobile.current) {
-                       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 (isMobile.current) return;
-
-               rootElement?.focus({ preventScroll: true });
-       }
-
-       export function resetHeight() {
-               if (rootElement) {
-                       rootElement.style.height = '';
-                       resizeHeight();
-               }
-       }
-</script>
-
-<div class="flex-1 {className}">
-       <div
-               bind:this={rootElement}
-               contenteditable={!disabled}
-               role="textbox"
-               aria-multiline="true"
-               aria-disabled={disabled}
-               aria-placeholder={placeholder}
-               data-placeholder={placeholder}
-               tabindex={disabled ? -1 : 0}
-               class={[
-                       'chat-form-contenteditable 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'
-               ]}
-               style="max-height: var(--max-message-height);"
-               oncompositionstart={handleCompositionStart}
-               oncompositionend={handleCompositionEnd}
-               oninput={handleInput}
-               onkeydown={handleKeydown}
-               onpaste={handlePaste}
-               oncopy={handleCopy}
-               oncut={handleCut}
-       ></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-contenteditable {
-               white-space: pre-wrap;
-       }
-
-       .chat-form-contenteditable:global([data-empty='true'])::before {
-               content: attr(data-placeholder);
-               color: var(--muted-foreground);
-               pointer-events: none;
-       }
-
-       /* Inline code - mirrors markdown-content.css */
-       .chat-form-contenteditable :global(code[data-code-token='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-contenteditable :global(code[data-code-token='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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte
new file mode 100644 (file)
index 0000000..78e5b5c
--- /dev/null
@@ -0,0 +1,413 @@
+<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 } from '$lib/constants';
+       import { BuiltInTool, GlobSearchType, KeyboardKey } 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 { 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.FILE_GLOB_SEARCH));
+       const fileSearchEnabled = $derived(
+               fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
+       );
+       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({
+               dataIndex: 'result',
+               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.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
+       type="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
+       ]}
+       onclick={onOpen}
+       {disabled}
+>
+       <ChatFormCurrentWorkingDirectoryChip
+               {directory}
+               {homeBase}
+               {disabled}
+               {showTooltip}
+               onClear={handleDismiss}
+       />
+</button>
+
+<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
+       <Popover.Trigger
+               class="pointer-events-none absolute inset-0 opacity-0"
+               tabindex={-1}
+               aria-hidden="true"
+       >
+               <span class="sr-only">Open working directory picker</span>
+       </Popover.Trigger>
+
+       <Popover.Content
+               side="top"
+               align="start"
+               sideOffset={12}
+               {customAnchor}
+               preventScroll={false}
+               onkeydown={handleKeydown}
+               onOpenAutoFocus={(event) => event.preventDefault()}
+               onCloseAutoFocus={(event) => event.preventDefault()}
+               class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
+       >
+               <div class="p-2 min-h-22 flex flex-col justify-between">
+                       <SearchInput
+                               bind:ref={searchInputRef}
+                               bind:value={query}
+                               placeholder="Choose working directory"
+                               onClose={closePicker}
+                               class="w-full"
+                       />
+
+                       {#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
+                                       results={queryResults}
+                                       hoveredIndex={nav.hoveredIndex}
+                                       isSearching={search.isSearching}
+                                       error={searchError}
+                                       rawQuery={query}
+                                       bind:container={listContainer}
+                                       onCommit={commit}
+                                       onHover={(index) => nav.setHover(index)}
+                               />
+                       {/if}
+
+                       {#if pickerSupported && fileSearchEnabled}
+                               <button
+                                       type="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}
+                               >
+                                       <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
+                                       <span>Browse</span>
+                               </button>
+                       {/if}
+
+                       {#if homeBase && fileSearchEnabled}
+                               <div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></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 />
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte
new file mode 100644 (file)
index 0000000..23661d2
--- /dev/null
@@ -0,0 +1,70 @@
+<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-muted-foreground inline-flex items-center gap-1 text-xs group"
+       class:text-foreground={directory}
+>
+       <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
+                               icon={X}
+                               tooltip="Reset working directory"
+                               ariaLabel="Reset working directory"
+                               {disabled}
+                               onclick={onClear}
+                               iconSize="h-3 w-3"
+                               stopPropagationOnClick
+                               class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
+                       />
+               </div>
+       {/if}
+</span>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte
new file mode 100644 (file)
index 0000000..32de061
--- /dev/null
@@ -0,0 +1,72 @@
+<script lang="ts">
+       import { Folder } from '@lucide/svelte';
+       import { cn } from '$lib/components/ui/utils';
+       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}
+       class="max-h-48 overflow-y-auto py-2"
+       transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
+>
+       {#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"
+                               data-result-index={index}
+                               data-highlighted={index === hoveredIndex ? '' : undefined}
+                               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'
+                               )}
+                               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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte
deleted file mode 100644 (file)
index 395ecb2..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-<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}
-       type="file"
-       {multiple}
-       onchange={handleFileSelect}
-       class="hidden {className}"
-/>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte
new file mode 100644 (file)
index 0000000..2b92b30
--- /dev/null
@@ -0,0 +1,80 @@
+<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;
+               useContenteditable?: boolean;
+       }
+
+       let {
+               class: className = '',
+               disabled = false,
+               onInput,
+               onKeydown,
+               onPaste,
+               placeholder = 'Ask anything...',
+               useContenteditable = 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 useContenteditable ? richRef?.getElement() : basicRef?.getElement();
+       }
+
+       export function focus() {
+               if (useContenteditable) richRef?.focus();
+               else basicRef?.focus();
+       }
+
+       export function resetHeight() {
+               if (useContenteditable) richRef?.resetHeight();
+               else basicRef?.resetHeight();
+       }
+
+       export function getCaretOffset(): number {
+               return useContenteditable
+                       ? (richRef?.getCaretOffset() ?? 0)
+                       : (basicRef?.getCaretOffset() ?? 0);
+       }
+
+       export function setCaretOffset(offset: number) {
+               if (useContenteditable) richRef?.setCaretOffset(offset);
+               else basicRef?.setCaretOffset(offset);
+       }
+</script>
+
+{#if useContenteditable}
+       <ChatFormInputRich
+               bind:this={richRef}
+               class={className}
+               {disabled}
+               {onInput}
+               {onKeydown}
+               {onPaste}
+               {placeholder}
+               bind:value
+       />
+{:else}
+       <ChatFormInputBasic
+               bind:this={basicRef}
+               class={className}
+               {disabled}
+               {onInput}
+               {onKeydown}
+               {onPaste}
+               {placeholder}
+               bind:value
+       />
+{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte
new file mode 100644 (file)
index 0000000..623744d
--- /dev/null
@@ -0,0 +1,82 @@
+<script lang="ts">
+       import { isMobile } 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 (isMobile.current) return;
+
+               textareaElement?.focus({ preventScroll: true });
+       }
+
+       export function resetHeight() {
+               if (textareaElement) {
+                       textareaElement.style.height = '1rem';
+               }
+       }
+
+       // Plain-text caret offsets, shared with the contenteditable 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'
+               ]}
+               style="max-height: var(--max-message-height);"
+               {disabled}
+               onkeydown={onKeydown}
+               oninput={(event) => {
+                       autoResizeTextarea(event.currentTarget);
+                       onInput?.();
+               }}
+               onpaste={onPaste}
+               {placeholder}
+       ></textarea>
+</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte
new file mode 100644 (file)
index 0000000..395ecb2
--- /dev/null
@@ -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}
+       type="file"
+       {multiple}
+       onchange={handleFileSelect}
+       class="hidden {className}"
+/>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte
new file mode 100644 (file)
index 0000000..f25c3c9
--- /dev/null
@@ -0,0 +1,849 @@
+<script lang="ts">
+       import { CODE_BLOCK } from '$lib/constants';
+       import { ColorMode } from '$lib/enums';
+       import { isMobile } 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 contenteditable 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 ? 'true' : '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 contenteditable 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[data-code-token="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.dataset.codeToken === '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[data-highlight-theme-preview]').forEach((s) => s.remove());
+
+               const style = document.createElement('style');
+
+               style.setAttribute('data-highlight-theme-preview', '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 contenteditable 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.dataset.codeToken === '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.dataset.codeToken !== '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 contenteditable 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.dataset.codeToken !== '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;
+                       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 (!isMobile.current) {
+                       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 (isMobile.current) 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}
+               contenteditable={!disabled}
+               role="textbox"
+               aria-multiline="true"
+               aria-disabled={disabled}
+               aria-placeholder={placeholder}
+               data-placeholder={placeholder}
+               tabindex={disabled ? -1 : 0}
+               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'
+               ]}
+               style="max-height: var(--max-message-height);"
+               oncompositionstart={handleCompositionStart}
+               oncompositionend={handleCompositionEnd}
+               oninput={handleInput}
+               onkeydown={handleKeydown}
+               onpaste={handlePaste}
+               oncopy={handleCopy}
+               oncut={handleCut}
+       ></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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte
deleted file mode 100644 (file)
index df654b2..0000000
+++ /dev/null
@@ -1,142 +0,0 @@
-<script lang="ts">
-       import { FolderOpen, Sparkles } from '@lucide/svelte';
-       import {
-               ChatFormPickerList,
-               ChatFormPickerListItem,
-               ChatFormPickerPopover
-       } from '$lib/components/app/chat';
-       import { MODEL_SELECTOR_ICON } from '$lib/constants';
-       import { ChatFormCommandAction } from '$lib/enums';
-       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 commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
-               [ChatFormCommandAction.CWD]: FolderOpen,
-               [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
-               [ChatFormCommandAction.PROMPT]: Sparkles
-       };
-
-       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}
-       srLabel="Open command picker"
-       {onClose}
-       onKeydown={handleKeydown}
->
-       <ChatFormPickerList
-               items={filteredCommands}
-               isLoading={false}
-               selectedIndex={nav.hoveredIndex}
-               showSearchInput={false}
-               searchQuery={query ?? ''}
-               emptyMessage="No matching command"
-               itemKey={(command) => command.name}
-               scrollTrigger={nav.scrollTrigger}
-       >
-               {#snippet item(command, index, isSelected)}
-                       {@const Icon = commandIcon[command.action]}
-                       <ChatFormPickerListItem
-                               dataIndex={index}
-                               {isSelected}
-                               disabled={command.disabled}
-                               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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte
deleted file mode 100644 (file)
index 1c7c8f7..0000000
+++ /dev/null
@@ -1,278 +0,0 @@
-<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 } from '$lib/enums';
-       import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
-       import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
-       import { isMobile, 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.FILE_GLOB_SEARCH));
-       const fileSearchEnabled = $derived(
-               fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
-       );
-
-       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(!isMobile.current);
-
-       $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
-       open={isOpen}
-       onOpenChange={(open) => {
-               if (!open) onClose();
-       }}
->
-       <!-- 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
-               class="pointer-events-none absolute inset-0 opacity-0"
-               tabindex={-1}
-               aria-hidden="true"
-       >
-               <span class="sr-only">Open file mention picker</span>
-       </Popover.Trigger>
-
-       <Popover.Content
-               align="start"
-               side="top"
-               sideOffset={12}
-               {customAnchor}
-               preventScroll={false}
-               onkeydown={handleKeydown}
-               onOpenAutoFocus={(event) => event.preventDefault()}
-               onCloseAutoFocus={(event) => event.preventDefault()}
-               class={[
-                       'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
-                       className
-               ]}
-       >
-               <ChatFormPickerList
-                       items={displayedItems}
-                       isLoading={search.isSearching}
-                       selectedIndex={nav.hoveredIndex}
-                       showSearchInput={false}
-                       searchQuery={query ?? ''}
-                       {emptyMessage}
-                       itemKey={(entry) => entry.type + ':' + entry.path}
-                       scrollTrigger={nav.scrollTrigger}
-               >
-                       {#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 text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
-                                               </span>
-                                       </div>
-                               </ChatFormPickerListItem>
-                       {/snippet}
-               </ChatFormPickerList>
-       </Popover.Content>
-</Popover.Root>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte
new file mode 100644 (file)
index 0000000..df654b2
--- /dev/null
@@ -0,0 +1,142 @@
+<script lang="ts">
+       import { FolderOpen, Sparkles } from '@lucide/svelte';
+       import {
+               ChatFormPickerList,
+               ChatFormPickerListItem,
+               ChatFormPickerPopover
+       } from '$lib/components/app/chat';
+       import { MODEL_SELECTOR_ICON } from '$lib/constants';
+       import { ChatFormCommandAction } from '$lib/enums';
+       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 commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
+               [ChatFormCommandAction.CWD]: FolderOpen,
+               [ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON,
+               [ChatFormCommandAction.PROMPT]: Sparkles
+       };
+
+       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}
+       srLabel="Open command picker"
+       {onClose}
+       onKeydown={handleKeydown}
+>
+       <ChatFormPickerList
+               items={filteredCommands}
+               isLoading={false}
+               selectedIndex={nav.hoveredIndex}
+               showSearchInput={false}
+               searchQuery={query ?? ''}
+               emptyMessage="No matching command"
+               itemKey={(command) => command.name}
+               scrollTrigger={nav.scrollTrigger}
+       >
+               {#snippet item(command, index, isSelected)}
+                       {@const Icon = commandIcon[command.action]}
+                       <ChatFormPickerListItem
+                               dataIndex={index}
+                               {isSelected}
+                               disabled={command.disabled}
+                               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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte
new file mode 100644 (file)
index 0000000..1c7c8f7
--- /dev/null
@@ -0,0 +1,278 @@
+<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 } from '$lib/enums';
+       import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
+       import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
+       import { isMobile, 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.FILE_GLOB_SEARCH));
+       const fileSearchEnabled = $derived(
+               fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
+       );
+
+       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(!isMobile.current);
+
+       $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
+       open={isOpen}
+       onOpenChange={(open) => {
+               if (!open) onClose();
+       }}
+>
+       <!-- 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
+               class="pointer-events-none absolute inset-0 opacity-0"
+               tabindex={-1}
+               aria-hidden="true"
+       >
+               <span class="sr-only">Open file mention picker</span>
+       </Popover.Trigger>
+
+       <Popover.Content
+               align="start"
+               side="top"
+               sideOffset={12}
+               {customAnchor}
+               preventScroll={false}
+               onkeydown={handleKeydown}
+               onOpenAutoFocus={(event) => event.preventDefault()}
+               onCloseAutoFocus={(event) => event.preventDefault()}
+               class={[
+                       'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
+                       className
+               ]}
+       >
+               <ChatFormPickerList
+                       items={displayedItems}
+                       isLoading={search.isSearching}
+                       selectedIndex={nav.hoveredIndex}
+                       showSearchInput={false}
+                       searchQuery={query ?? ''}
+                       {emptyMessage}
+                       itemKey={(entry) => entry.type + ':' + entry.path}
+                       scrollTrigger={nav.scrollTrigger}
+               >
+                       {#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 text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
+                                               </span>
+                                       </div>
+                               </ChatFormPickerListItem>
+                       {/snippet}
+               </ChatFormPickerList>
+       </Popover.Content>
+</Popover.Root>
index 7ba97cf9b4a36340de2c291a28191862ed6ec88e..dbe03e2e01ac0bed47b73552fe3c30c4568ebf57 100644 (file)
@@ -1,7 +1,7 @@
 <script lang="ts">
-       import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
-       import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
+       import ChatFormPickerCommand from './ChatFormPickerCommand.svelte';
        import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
+       import ChatFormPickerMention from './ChatFormPickerMention.svelte';
        import type {
                ChatFormCommand,
                FileMentionEntry,
@@ -55,9 +55,9 @@
                scopePath
        }: Props = $props();
 
-       let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
+       let commandPickerRef: ChatFormPickerCommand | undefined = $state(undefined);
        let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
-       let mentionPickerRef: ChatFormMentionPicker | 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 {
@@ -77,7 +77,7 @@
        }
 </script>
 
-<ChatFormCommandPicker
+<ChatFormPickerCommand
        bind:this={commandPickerRef}
        isOpen={isCommandPickerOpen ?? false}
        query={commandQuery ?? ''}
@@ -96,7 +96,7 @@
        {onPromptLoadError}
 />
 
-<ChatFormMentionPicker
+<ChatFormPickerMention
        bind:this={mentionPickerRef}
        isOpen={isMentionPickerOpen ?? false}
        query={mentionQuery ?? ''}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte
deleted file mode 100644 (file)
index 623744d..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-<script lang="ts">
-       import { isMobile } 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 (isMobile.current) return;
-
-               textareaElement?.focus({ preventScroll: true });
-       }
-
-       export function resetHeight() {
-               if (textareaElement) {
-                       textareaElement.style.height = '1rem';
-               }
-       }
-
-       // Plain-text caret offsets, shared with the contenteditable 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'
-               ]}
-               style="max-height: var(--max-message-height);"
-               {disabled}
-               onkeydown={onKeydown}
-               oninput={(event) => {
-                       autoResizeTextarea(event.currentTarget);
-                       onInput?.();
-               }}
-               onpaste={onPaste}
-               {placeholder}
-       ></textarea>
-</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte
deleted file mode 100644 (file)
index b1652d0..0000000
+++ /dev/null
@@ -1,413 +0,0 @@
-<script lang="ts">
-       import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
-       import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.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 } from '$lib/constants';
-       import { BuiltInTool, GlobSearchType, KeyboardKey } 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 { 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.FILE_GLOB_SEARCH));
-       const fileSearchEnabled = $derived(
-               fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
-       );
-       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({
-               dataIndex: 'result',
-               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.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('[ChatFormWorkingDirectory] 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
-       type="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
-       ]}
-       onclick={onOpen}
-       {disabled}
->
-       <ChatFormWorkingDirectoryChip
-               {directory}
-               {homeBase}
-               {disabled}
-               {showTooltip}
-               onClear={handleDismiss}
-       />
-</button>
-
-<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
-       <Popover.Trigger
-               class="pointer-events-none absolute inset-0 opacity-0"
-               tabindex={-1}
-               aria-hidden="true"
-       >
-               <span class="sr-only">Open working directory picker</span>
-       </Popover.Trigger>
-
-       <Popover.Content
-               side="top"
-               align="start"
-               sideOffset={12}
-               {customAnchor}
-               preventScroll={false}
-               onkeydown={handleKeydown}
-               onOpenAutoFocus={(event) => event.preventDefault()}
-               onCloseAutoFocus={(event) => event.preventDefault()}
-               class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
-       >
-               <div class="p-2 min-h-22 flex flex-col justify-between">
-                       <SearchInput
-                               bind:ref={searchInputRef}
-                               bind:value={query}
-                               placeholder="Choose working directory"
-                               onClose={closePicker}
-                               class="w-full"
-                       />
-
-                       {#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)}
-                               <ChatFormWorkingDirectoryResultsList
-                                       results={queryResults}
-                                       hoveredIndex={nav.hoveredIndex}
-                                       isSearching={search.isSearching}
-                                       error={searchError}
-                                       rawQuery={query}
-                                       bind:container={listContainer}
-                                       onCommit={commit}
-                                       onHover={(index) => nav.setHover(index)}
-                               />
-                       {/if}
-
-                       {#if pickerSupported && fileSearchEnabled}
-                               <button
-                                       type="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}
-                               >
-                                       <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
-                                       <span>Browse</span>
-                               </button>
-                       {/if}
-
-                       {#if homeBase && fileSearchEnabled}
-                               <div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></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 />
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte
deleted file mode 100644 (file)
index 23661d2..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-<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-muted-foreground inline-flex items-center gap-1 text-xs group"
-       class:text-foreground={directory}
->
-       <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
-                               icon={X}
-                               tooltip="Reset working directory"
-                               ariaLabel="Reset working directory"
-                               {disabled}
-                               onclick={onClear}
-                               iconSize="h-3 w-3"
-                               stopPropagationOnClick
-                               class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
-                       />
-               </div>
-       {/if}
-</span>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte
deleted file mode 100644 (file)
index 32de061..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-<script lang="ts">
-       import { Folder } from '@lucide/svelte';
-       import { cn } from '$lib/components/ui/utils';
-       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}
-       class="max-h-48 overflow-y-auto py-2"
-       transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
->
-       {#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"
-                               data-result-index={index}
-                               data-highlighted={index === hoveredIndex ? '' : undefined}
-                               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'
-                               )}
-                               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>
index 5c8848264865b52d084b95ae274ebe451e00290c..d9e408c96e5775604ece92482545b2e0a84ae65b 100644 (file)
@@ -91,7 +91,7 @@ export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachme
  * preview without carousel, or a gallery/carousel view when multiple items exist.
  * Uses ChatAttachmentPreviewSingle internally for each item's content.
  */
-export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte';
+export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte';
 export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte';
 export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte';
 export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte';
@@ -120,8 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
  * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
  *
  * **Architecture:**
- * - Composes ChatFormTextarea (or ChatFormContentEditable for messages with
- *   file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
+ * - Composes ChatFormInput (a plain textarea, or a contenteditable for
+ *   messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
  * - Manages file upload state via `uploadedFiles` bindable prop
  * - Integrates with ModelsSelectorDropdown for model selection in router mode
  * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -258,7 +258,7 @@ export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge
 /**
  * Hidden file input element for programmatic file selection.
  */
-export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte';
+export { default as ChatFormInputFileInputInvisible } from './ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte';
 
 /**
  * Displays MCP Resource attachments as a horizontal carousel.
@@ -267,18 +267,13 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
 export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
 
 /**
- * Auto-resizing contenteditable input that renders `[name](file://...)`
- * mention links as inline chips while keeping the value as the markdown
- * source string. ChatForm swaps it in once a mention link lands in the
- * buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
+ * The message editor. Renders a plain auto-resizing textarea by default,
+ * or a contenteditable that renders `[name](file://...)` mention links as
+ * inline chips (keeping the value as the markdown source string) once a
+ * mention link lands in the buffer. The variant is selected via the
+ * `useContenteditable` prop; both share one imperative handle.
  */
-export { default as ChatFormContentEditable } from './ChatForm/ChatFormContentEditable.svelte';
-
-/**
- * Plain auto-resizing textarea with IME composition support. Default input
- * renderer inside ChatForm until a file mention lands.
- */
-export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
+export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte';
 
 /**
  * Working directory selector for agent mode. Renders a chip below the chat
@@ -288,7 +283,7 @@ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'
  * synthetic "Set working directory to ..." user message into chat history
  * and is enforced on tool calls via the `x-tool-cwd` request header.
  */
-export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte';
+export { default as ChatFormCurrentWorkingDirectory } from './ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte';
 
 /**
  * **ChatFormPickerMcpPrompts** - MCP prompt selection interface
@@ -359,14 +354,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
  * Generic scrollable list for picker popovers. Provides search input,
  * scroll-into-view for keyboard navigation, loading skeletons, empty state,
  * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
- * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
+ * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention.
  */
 export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
 
 /**
  * Generic button wrapper for picker list items. Provides consistent styling,
  * hover/selected states, and data-picker-index attribute for scroll-into-view.
- * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
+ * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention.
  */
 export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
 
@@ -389,14 +384,14 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
  * tool, scoped to the conversation cwd (or server home when unset).
  * Selection splices a `[name](file:///<abs path>)` link into the input.
  */
-export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
+export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
 
 /**
  * `/`-triggered slash-command picker. Lists the available slash commands
  * (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
  * hands the command to the parent for dispatch.
  */
-export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
+export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte';
 
 /**
  * Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
index ac39695361926d9951f8dfb8ed7e3f1416cbf7f7..152b42c129e963481b0f8fdc074ca8b9d6fa9fdc 100644 (file)
@@ -91,11 +91,11 @@ export enum FileMentionEntryType {
 }
 
 /**
- * Kinds of tokens the chat-form contenteditable produces.
+ * Kinds of tokens the chat-form-input-rich produces.
  */
-export enum ContentEditableTokenKind {
+export enum ChatFormInputRichTokenKind {
        TEXT = 'text',
        BADGE = 'badge',
-       INLINE_CODE = 'inlineCode',
-       CODE_BLOCK = 'codeBlock'
+       CODE_INLINE = 'code_inline',
+       CODE_BLOCK = 'code_block'
 }
index 226cc134d038829c0676e4441f238eaaa481d2bd..5d0884b9865e555fb6b46c77ddcc1edda70e9200 100644 (file)
@@ -28,7 +28,7 @@ export {
        ReasoningFormat,
        ChatFormCommandAction,
        FileMentionEntryType,
-       ContentEditableTokenKind
+       ChatFormInputRichTokenKind
 } from './chat.enums';
 
 export { SessionRecordType } from './conversation-import.enums';
diff --git a/tools/ui/src/lib/types/chat-form-input-rich.d.ts b/tools/ui/src/lib/types/chat-form-input-rich.d.ts
new file mode 100644 (file)
index 0000000..307bc33
--- /dev/null
@@ -0,0 +1,11 @@
+import { ChatFormInputRichTokenKind } from '$lib/enums';
+
+/**
+ * A single token produced by the chat-form-input-rich tokenizer:
+ * plain text, a file/folder mention badge, or an inline/fenced code span.
+ */
+export type ChatFormInputRichToken =
+       | { kind: ChatFormInputRichTokenKind.TEXT; text: string }
+       | { kind: ChatFormInputRichTokenKind.BADGE; name: string; path: string }
+       | { kind: ChatFormInputRichTokenKind.CODE_INLINE; text: string }
+       | { kind: ChatFormInputRichTokenKind.CODE_BLOCK; text: string };
diff --git a/tools/ui/src/lib/types/contenteditable.d.ts b/tools/ui/src/lib/types/contenteditable.d.ts
deleted file mode 100644 (file)
index 8c610bf..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-import { ContentEditableTokenKind } from '$lib/enums';
-
-/**
- * A single token produced by the chat-form contenteditable tokenizer:
- * plain text, a file/folder mention badge, or an inline/fenced code span.
- */
-export type ContentEditableToken =
-       | { kind: ContentEditableTokenKind.TEXT; text: string }
-       | { kind: ContentEditableTokenKind.BADGE; name: string; path: string }
-       | { kind: ContentEditableTokenKind.INLINE_CODE; text: string }
-       | { kind: ContentEditableTokenKind.CODE_BLOCK; text: string };
index 34767e2ae7df2e8ef27cc2a776388b362b0251a9..497bfd415e0c0ab9567e8ed0e2d1bbaf77bb01fe 100644 (file)
@@ -183,7 +183,7 @@ export type {
 } from './glob';
 
 // Contenteditable token types (chat form)
-export type { ContentEditableToken } from './contenteditable';
+export type { ChatFormInputRichToken } from './chat-form-input-rich';
 
 // Agentic types
 export type {
diff --git a/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts b/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts
new file mode 100644 (file)
index 0000000..7af350b
--- /dev/null
@@ -0,0 +1,1059 @@
+/**
+ * Maps between the chat-form-input-rich's markdown source and the
+ * badge/code/text token stream the DOM is built from. A badge is one
+ * opaque source contribution (`[name](file://path)`); its own subtree
+ * is never walked, and the caret cannot land inside it, so offsets
+ * resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
+ * are EDITABLE, unlike badges: they carry the full source segment
+ * (backtick fences included) as their text, so their textContent
+ * serializes verbatim and source offsets map 1:1 to text offsets.
+ *
+ * The tokenizer emits a flat DOM (text nodes + badges + code spans),
+ * but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
+ * shapes). Serialization folds those back into `\n` so the source
+ * never diverges from what is on screen; both offset mappers
+ * understand the same shapes.
+ *
+ * The newline separating a fenced block from adjacent content is a
+ * SOURCE-level concept, never stored in the DOM: the block is
+ * display:block, so a leading `\n` in the following text node would
+ * render as a phantom empty line. Serialization synthesizes exactly
+ * one `\n` at every block boundary and `buildFragment` strips it from
+ * text tokens. A text node's own leading/trailing `\n` next to a
+ * block is an ADDITIONAL blank line.
+ */
+
+import {
+       decodeFileLinkPath,
+       fileMentionLinkRe,
+       getMentionBadgeIconPaths,
+       getMentionBadgeLabel
+} from './mention-badge';
+import {
+       MENTION_BADGE_CLASSNAME,
+       MENTION_BADGE_ICON_CLASSNAME,
+       MENTION_BADGE_SVG_ATTRIBUTES,
+       SETTINGS_KEYS
+} from '$lib/constants';
+import { ChatFormInputRichTokenKind } from '$lib/enums';
+import { settingsStore } from '$lib/stores/settings.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
+import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
+
+// Block wrappers browsers insert for newlines; each folds back into a
+// single `\n` during serialization.
+const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
+// `file://` is required so plain URLs stay as text; `)` terminates only
+// when not followed by whitespace or `[` (adjacent badges keep working).
+const MENTION_BADGE_RE = fileMentionLinkRe('g');
+
+function badgeSourceLength(name: string, path: string): number {
+       if (!name || !path) return 0;
+
+       return `[${name}](file://${path})`.length;
+}
+
+/**
+ * Recognize complete code spans. Fenced blocks (triple backticks,
+ * optional language, possibly multiline) take priority over inline
+ * spans (single backticks, single line, non-empty). Only CLOSED
+ * spans match: an unclosed fence stays plain text until the closing
+ * backticks land. The match includes the fences so the token's
+ * source length equals its rendered text length.
+ */
+const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
+
+/**
+ * Cheap gate check for `ChatForm`: does the buffer contain a
+ * complete code span (inline or fenced)? Used to promote the plain
+ * textarea to the chat-form-input-rich renderer.
+ */
+export function containsCodeSpan(value: string): boolean {
+       CODE_SPAN_RE.lastIndex = 0;
+
+       return CODE_SPAN_RE.test(value);
+}
+
+const CODE_FENCE_RE = /```/g;
+
+/**
+ * Is `offset` inside a fenced code block region? Toggle-based: an
+ * odd number of ``` fences before the offset means the position
+ * sits in block content. Unlike `containsCodeSpan` this also
+ * counts the still-OPEN fence while the user is typing a block
+ * (no closing ``` yet), so Enter can add a line instead of
+ * submitting the message.
+ */
+export function isOffsetInCodeBlock(source: string, offset: number): boolean {
+       let inside = false;
+
+       CODE_FENCE_RE.lastIndex = 0;
+
+       let match: RegExpExecArray | null;
+
+       while ((match = CODE_FENCE_RE.exec(source)) !== null) {
+               if (match.index + match[0].length > offset) break;
+
+               inside = !inside;
+       }
+
+       return inside;
+}
+
+/**
+ * Tokenize a markdown source value into the segments the
+ * chat-form-input-rich will render. Code spans are carved out first
+ * (their content is literal - a `file://` link inside backticks
+ * must NOT render as a badge), then plain text and badges
+ * interleave in the remaining gaps. Any whitespace after a badge
+ * stays in a plain text token so the round trip is byte-exact.
+ */
+export function tokenizeContent(input: string): ChatFormInputRichToken[] {
+       const tokens: ChatFormInputRichToken[] = [];
+
+       let cursor = 0;
+
+       CODE_SPAN_RE.lastIndex = 0;
+
+       let match: RegExpExecArray | null;
+
+       while ((match = CODE_SPAN_RE.exec(input)) !== null) {
+               const start = match.index;
+
+               if (start > cursor) {
+                       pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
+               }
+
+               tokens.push(
+                       match[1] !== undefined
+                               ? { kind: ChatFormInputRichTokenKind.CODE_BLOCK, text: match[1] }
+                               : { kind: ChatFormInputRichTokenKind.CODE_INLINE, text: match[2] }
+               );
+               cursor = start + match[0].length;
+       }
+
+       if (cursor < input.length) {
+               pushTextAndBadgeTokens(input.slice(cursor), tokens);
+       }
+
+       return tokens;
+}
+
+/**
+ * Tokenize a code-free segment into text and badge tokens.
+ */
+function pushTextAndBadgeTokens(input: string, tokens: ChatFormInputRichToken[]) {
+       let cursor = 0;
+
+       MENTION_BADGE_RE.lastIndex = 0;
+
+       let match: RegExpExecArray | null;
+
+       while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
+               const [whole, name, path] = match;
+               const start = match.index;
+
+               if (start > cursor) {
+                       tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor, start) });
+               }
+
+               tokens.push({ kind: ChatFormInputRichTokenKind.BADGE, name, path });
+               cursor = start + whole.length;
+       }
+
+       if (cursor < input.length) {
+               tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor) });
+       }
+}
+
+function isCodeBlockElement(node: Node | null): node is HTMLElement {
+       return (
+               node instanceof HTMLElement && node.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
+       );
+}
+
+/**
+ * Serialize a chat-form-input-rich subtree back to source. `<br>` and block
+ * wrappers the browser inserted for newlines fold back into `\n` (a
+ * trailing `<br>` is the browser's caret placeholder, not a newline);
+ * any other element is transparent. Code spans serialize their
+ * textContent verbatim (fences included). One separator `\n` is
+ * synthesized at every fenced-block boundary (the DOM never stores
+ * it), and a `<br>` adjacent to a code block is an escape hatch, not
+ * a newline.
+ */
+export function serializeContent(root: HTMLElement): string {
+       let out = '';
+       let pendingBlockBoundary = false;
+
+       const walk = (parent: Node) => {
+               let first = true; // no source-contributing sibling seen yet
+
+               for (const child of Array.from(parent.childNodes)) {
+                       if (child.nodeType === Node.TEXT_NODE) {
+                               const text = child.textContent ?? '';
+
+                               if (text.length > 0) {
+                                       if (pendingBlockBoundary) {
+                                               out += '\n';
+                                               pendingBlockBoundary = false;
+                                       }
+
+                                       out += text;
+                                       first = false;
+                               }
+
+                               continue;
+                       }
+
+                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
+
+                       const el = child as HTMLElement;
+
+                       if (el.dataset.mentionBadge === 'true') {
+                               const name = el.dataset.mentionName ?? '';
+                               const path = el.dataset.mentionPath ?? '';
+
+                               if (name && path) {
+                                       if (pendingBlockBoundary) {
+                                               out += '\n';
+                                               pendingBlockBoundary = false;
+                                       }
+
+                                       out += `[${name}](file://${path})`;
+                                       first = false;
+                               }
+
+                               continue;
+                       }
+
+                       if (el.dataset.codeToken !== undefined) {
+                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+
+                               if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
+
+                               pendingBlockBoundary = false;
+                               walk(el);
+                               first = false;
+
+                               if (isBlock) pendingBlockBoundary = true;
+
+                               continue;
+                       }
+
+                       if (el.tagName === 'BR') {
+                               const isHatch =
+                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
+                               if (!isHatch && el.nextSibling) {
+                                       if (pendingBlockBoundary) {
+                                               out += '\n';
+                                               pendingBlockBoundary = false;
+                                       }
+
+                                       out += '\n';
+                                       first = false;
+                               }
+
+                               continue;
+                       }
+
+                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
+                               if (pendingBlockBoundary || !first) out += '\n';
+
+                               pendingBlockBoundary = false;
+                               walk(el);
+                               first = false;
+
+                               continue;
+                       }
+
+                       walk(el);
+
+                       if (pendingBlockBoundary) first = false;
+               }
+       };
+
+       walk(root);
+
+       return out;
+}
+
+/**
+ * Compare the live DOM's non-text structure against a token stream.
+ * Only element contributions are compared (badges by name/path, code
+ * spans by kind and source segment): text nodes are owned by the
+ * browser between rebuilds, so their split/merge state is irrelevant.
+ * A mismatch means token boundaries shifted (a code span was just
+ * completed or broken) and the DOM needs a rebuild to restyle.
+ */
+export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichToken[]): boolean {
+       const expected = tokens.filter((token) => token.kind !== ChatFormInputRichTokenKind.TEXT);
+
+       let index = 0;
+
+       const walk = (parent: Node): boolean => {
+               for (const child of Array.from(parent.childNodes)) {
+                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
+
+                       const el = child as HTMLElement;
+                       const isBadge = el.dataset.mentionBadge === 'true';
+                       const isCode = el.dataset.codeToken !== undefined;
+
+                       if (!isBadge && !isCode) {
+                               if (!walk(el)) return false;
+
+                               continue;
+                       }
+
+                       const token = expected[index++];
+
+                       if (!token) return false;
+
+                       if (isBadge) {
+                               if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
+
+                               if (token.name !== (el.dataset.mentionName ?? '')) return false;
+
+                               if (token.path !== (el.dataset.mentionPath ?? '')) return false;
+
+                               continue;
+                       }
+
+                       const codeKind: ChatFormInputRichTokenKind =
+                               el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK
+                                       ? ChatFormInputRichTokenKind.CODE_BLOCK
+                                       : ChatFormInputRichTokenKind.CODE_INLINE;
+
+                       if (token.kind !== codeKind) return false;
+
+                       if (
+                               token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
+                               token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
+                       ) {
+                               if (token.text !== (el.textContent ?? '')) return false;
+                       }
+               }
+
+               return true;
+       };
+
+       return walk(root) && index === expected.length;
+}
+
+/**
+ * Plain-text offset of a `Range` in the root; null range (selection
+ * lost) falls back to buffer length. Walked against the live DOM (not
+ * a clone) so a `<br>` keeps its trailing/not-trailing context. Code
+ * spans count their full textContent (fences included) and the caret
+ * may land inside them; synthesized block boundaries count one `\n`
+ * once the caret is past them.
+ */
+export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
+       if (!range) return serializeContent(root).length;
+
+       // A point is at/before the caret iff it falls inside [root start, caret].
+       const pre = range.cloneRange();
+
+       pre.selectNodeContents(root);
+       pre.setEnd(range.endContainer, range.endOffset);
+       const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
+
+       let total = 0;
+       let done = false;
+       // DOM position of a code block's synthesized after-boundary, set
+       // when walking past a block and consumed by the next contributing
+       // sibling (counts one `\n` once the caret is past it).
+       let pendingPoint: { node: Node; index: number } | null = null;
+
+       const walk = (parent: Node) => {
+               let first = true;
+
+               for (const child of Array.from(parent.childNodes)) {
+                       if (done) return;
+
+                       if (child.nodeType === Node.TEXT_NODE) {
+                               const text = child.textContent ?? '';
+
+                               if (text.length === 0) continue;
+
+                               if (pendingPoint) {
+                                       const { index, node } = pendingPoint;
+
+                                       pendingPoint = null;
+
+                                       if (!atOrBeforeCaret(node, index)) {
+                                               done = true;
+
+                                               return;
+                                       }
+
+                                       total += 1;
+                               }
+
+                               if (!atOrBeforeCaret(child, 0)) {
+                                       done = true;
+
+                                       return;
+                               }
+
+                               if (range.endContainer === child) {
+                                       total += range.endOffset;
+                                       done = true;
+
+                                       return;
+                               }
+
+                               total += text.length;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
+
+                       const el = child as HTMLElement;
+                       const parentNode = el.parentNode as Node;
+                       const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
+
+                       if (pendingPoint) {
+                               const { index, node } = pendingPoint;
+
+                               pendingPoint = null;
+
+                               if (!atOrBeforeCaret(node, index)) {
+                                       done = true;
+
+                                       return;
+                               }
+
+                               total += 1;
+                       }
+
+                       if (el.dataset.mentionBadge === 'true') {
+                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+
+                               if (len === 0) continue;
+
+                               if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
+                                       done = true;
+
+                                       return;
+                               }
+
+                               total += len;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (el.dataset.codeToken !== undefined) {
+                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+
+                               if (isBlock && !first) {
+                                       if (!atOrBeforeCaret(el, 0)) {
+                                               done = true;
+
+                                               return;
+                                       }
+
+                                       total += 1;
+                               }
+
+                               walk(el);
+                               first = false;
+
+                               if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
+
+                               continue;
+                       }
+
+                       if (el.tagName === 'BR') {
+                               const isHatch =
+                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
+                               if (isHatch || !el.nextSibling) continue;
+
+                               if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
+                                       done = true;
+
+                                       return;
+                               }
+
+                               total += 1;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
+                               if (!first) {
+                                       if (!atOrBeforeCaret(el, 0)) {
+                                               done = true;
+
+                                               return;
+                                       }
+
+                                       total += 1;
+                               }
+
+                               walk(el);
+                               first = false;
+
+                               continue;
+                       }
+
+                       const before = total;
+
+                       walk(el);
+
+                       if (total > before) first = false;
+               }
+       };
+
+       walk(root);
+
+       return total;
+}
+
+/**
+ * Materialize a token stream into a DOM subtree: text nodes for text
+ * tokens, `<span data-mention-badge="true">` elements for badges,
+ * `<code data-code-token>` elements for code spans. The badge's class
+ * string + inline SVG are shared with the rehype plugin via
+ * `$lib/constants`.
+ */
+export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragment {
+       const fragment = document.createDocumentFragment();
+
+       for (let index = 0; index < tokens.length; index++) {
+               const token = tokens[index];
+
+               if (token.kind === ChatFormInputRichTokenKind.TEXT) {
+                       let text = token.text;
+
+                       // The separator \n at a fenced-block boundary is synthesized
+                       // at serialization time; keeping it in the DOM would render a
+                       // phantom empty line next to the block.
+                       if (
+                               tokens[index - 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
+                               text.startsWith('\n')
+                       ) {
+                               text = text.slice(1);
+                       }
+
+                       if (
+                               tokens[index + 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
+                               text.endsWith('\n')
+                       ) {
+                               text = text.slice(0, -1);
+                       }
+
+                       if (text.length === 0) continue;
+
+                       fragment.appendChild(document.createTextNode(text));
+
+                       continue;
+               }
+
+               if (
+                       token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
+                       token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
+               ) {
+                       const code = document.createElement('code');
+
+                       code.dataset.codeToken = token.kind;
+                       code.textContent = token.text;
+                       fragment.appendChild(code);
+
+                       continue;
+               }
+
+               // A leading badge gets an empty text node prepended: without a real
+               // text position at the buffer start, the spot before the badge is
+               // unreachable via keyboard (ArrowLeft/Home).
+               if (!fragment.lastChild) {
+                       fragment.appendChild(document.createTextNode(''));
+               }
+
+               const badge = document.createElement('span');
+
+               badge.dataset.mentionBadge = 'true';
+               badge.dataset.mentionName = token.name;
+               badge.dataset.mentionPath = token.path;
+               badge.title = decodeFileLinkPath(token.path);
+               badge.className = MENTION_BADGE_CLASSNAME;
+               badge.contentEditable = 'false';
+
+               const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
+
+               for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
+                       svg.setAttribute(attr, value);
+               }
+               for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
+                       svg.classList.add(cls);
+               }
+
+               for (const d of getMentionBadgeIconPaths(token.path)) {
+                       const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
+
+                       path.setAttribute('d', d);
+                       svg.appendChild(path);
+               }
+
+               const label = document.createElement('span');
+
+               label.classList.add('shrink-0', 'truncate');
+               label.textContent = getMentionBadgeLabel(
+                       token.name,
+                       decodeFileLinkPath(token.path),
+                       settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
+                       toolsStore.serverHome
+               );
+
+               badge.appendChild(svg);
+               badge.appendChild(label);
+               fragment.appendChild(badge);
+       }
+
+       return fragment;
+}
+
+// A sibling provides a reachable caret line when it is an element
+// (badge, another block, an existing hatch) or a non-empty text node.
+function hasLineBeside(node: Node | null): boolean {
+       if (!node) return false;
+
+       if (node.nodeType === Node.ELEMENT_NODE) return true;
+
+       return (node.textContent ?? '') !== '';
+}
+
+/**
+ * A code block at the END of the buffer needs an editable line after
+ * it: without one the caret cannot leave the block with
+ * ArrowDown/ArrowRight. A trailing `<br>` provides that line while
+ * staying transparent to serialization (skipped as a hatch), and is
+ * removed again once real content takes its place.
+ *
+ * No hatch is added BEFORE a leading block: the empty line above it
+ * is transient and managed by the component (created when the caret
+ * arrows onto it, removed when the caret leaves). A transient
+ * leading hatch found here is kept; the browser's lone placeholder
+ * `<br>` in an empty root is left untouched.
+ */
+export function syncCodeBlockHatches(root: HTMLElement) {
+       for (const child of Array.from(root.childNodes)) {
+               if (child.nodeName !== 'BR') continue;
+
+               const isPlaceholder = root.childNodes.length === 1;
+               const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
+               const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
+
+               // A hatch goes stale once real content takes over its line:
+               // content before a leading hatch, content after a trailing one,
+               // or a text node after the block already providing the line.
+               // A `<br>` with no code block around is a real newline (browser
+               // Shift+Enter shape) and stays.
+               let prevElement = child.previousSibling;
+
+               while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
+                       prevElement = prevElement.previousSibling;
+               }
+               const nearBlock =
+                       isCodeBlockElement(child.nextSibling) ||
+                       isCodeBlockElement(child.previousSibling) ||
+                       isCodeBlockElement(prevElement);
+
+               if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
+                       child.remove();
+               }
+       }
+
+       for (const child of Array.from(root.childNodes)) {
+               if (!isCodeBlockElement(child)) continue;
+
+               if (!hasLineBeside(child.nextSibling)) {
+                       child.after(document.createElement('br'));
+               }
+       }
+}
+
+/**
+ * Strip the separator and artificial newlines from an all-newline text
+ * node directly after a fenced block. Chromium's line break at the
+ * buffer end inserts an extra artificial `\n` so the new line has
+ * height, and the first `\n` after a block doubles as the fence's
+ * separator line (synthesized at serialization time). Removing both
+ * makes Shift+Enter after a block land the caret on the line directly
+ * below the block, like a plain textarea would.
+ *
+ * Only all-newline text nodes are touched: a node with real content
+ * carries intentional blank lines and is left alone. Returns true when
+ * the DOM changed.
+ */
+export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
+       let changed = false;
+
+       for (const child of Array.from(root.childNodes)) {
+               if (child.nodeType !== Node.TEXT_NODE) continue;
+
+               if (!isCodeBlockElement(child.previousSibling)) continue;
+
+               let text = child.textContent ?? '';
+
+               if (!/^\n{2,}$/.test(text)) continue;
+
+               text = text.slice(1);
+
+               const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
+
+               if (atBufferEnd) {
+                       text = text.slice(0, -1);
+               }
+
+               child.textContent = text;
+               changed = true;
+       }
+
+       return changed;
+}
+
+const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
+
+/**
+ * Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
+ * when the jump crosses no badge and native word movement should handle
+ * it. Badge spans are masked to word characters, so a badge counts as
+ * exactly one word.
+ */
+export function badgeAwareWordJump(
+       source: string,
+       offset: number,
+       direction: 'forward' | 'backward'
+): number | null {
+       let masked = '';
+
+       const badgeSpans: Array<[number, number]> = [];
+
+       for (const token of tokenizeContent(source)) {
+               const len =
+                       token.kind === ChatFormInputRichTokenKind.BADGE
+                               ? badgeSourceLength(token.name, token.path)
+                               : token.text.length;
+
+               if (token.kind === ChatFormInputRichTokenKind.BADGE)
+                       badgeSpans.push([masked.length, masked.length + len]);
+
+               masked += token.kind === ChatFormInputRichTokenKind.BADGE ? 'a'.repeat(len) : token.text;
+       }
+
+       if (badgeSpans.length === 0) return null;
+
+       const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
+       const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
+       const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
+       const n = masked.length;
+
+       let i = offset;
+
+       if (direction === 'forward') {
+               // Entering a badge completes the word phase at the badge's end edge.
+               if (!(i < n && isWord(i))) {
+                       while (i < n && !isWord(i)) i++;
+               }
+
+               while (i < n && isWord(i)) {
+                       const span = spanStartingAt(i);
+
+                       if (span) {
+                               i = span[1];
+
+                               break;
+                       }
+
+                       i++;
+               }
+       } else {
+               if (!(i > 0 && isWord(i - 1))) {
+                       while (i > 0 && !isWord(i - 1)) i--;
+               }
+
+               while (i > 0 && isWord(i - 1)) {
+                       const span = spanEndingAt(i);
+
+                       if (span) {
+                               i = span[0];
+
+                               break;
+                       }
+
+                       i--;
+               }
+       }
+
+       if (i === offset) return null;
+
+       const lo = Math.min(offset, i);
+       const hi = Math.max(offset, i);
+
+       return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
+}
+
+/**
+ * 0 when `caret` sits exactly at a leading badge's end edge, null
+ * otherwise. Plain ArrowLeft there has no native previous position, so
+ * the host snaps the caret to the buffer start manually.
+ */
+export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
+       const [first] = tokenizeContent(source);
+
+       if (!first || first.kind !== ChatFormInputRichTokenKind.BADGE) return null;
+
+       return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
+}
+
+/**
+ * Translate a plain-text offset into a degenerate `Range` at that
+ * position in the DOM; out-of-range offsets clamp to buffer end (before
+ * a trailing escape hatch, not after it). Zero offset lands BEFORE a
+ * badge or code span, and an offset exactly at a code span's end lands
+ * AFTER it, so typing at a code span's edge extends the surrounding
+ * text. Interior code-span offsets land in the element's text.
+ * Understands the same block/`<br>` newline shapes as
+ * `serializeContent`.
+ */
+export function textOffsetToRange(root: HTMLElement, offset: number): Range {
+       const range = document.createRange();
+
+       let remaining = offset;
+       let landed = false;
+       let pendingBlockBoundary = false;
+
+       const land = (node: Node, nodeOffset: number) => {
+               range.setStart(node, nodeOffset);
+               range.setEnd(node, nodeOffset);
+               landed = true;
+       };
+       const walk = (parent: Node) => {
+               let first = true;
+
+               for (const child of Array.from(parent.childNodes)) {
+                       if (landed) return;
+
+                       if (child.nodeType === Node.TEXT_NODE) {
+                               const text = child.textContent ?? '';
+
+                               if (text.length === 0) continue;
+
+                               if (pendingBlockBoundary) {
+                                       // The synthesized separator maps to the near edge of the
+                                       // content that follows the block.
+                                       pendingBlockBoundary = false;
+
+                                       if (remaining === 0) {
+                                               land(child, 0);
+
+                                               return;
+                                       }
+
+                                       remaining -= 1;
+                               }
+
+                               if (remaining <= text.length) {
+                                       land(child, remaining);
+
+                                       return;
+                               }
+
+                               remaining -= text.length;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
+
+                       const el = child as HTMLElement;
+
+                       if (el.dataset.mentionBadge === 'true') {
+                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
+
+                               if (len === 0) continue;
+
+                               if (pendingBlockBoundary) {
+                                       pendingBlockBoundary = false;
+
+                                       if (remaining === 0) {
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                               landed = true;
+
+                                               return;
+                                       }
+
+                                       remaining -= 1;
+                               }
+
+                               if (remaining <= len) {
+                                       if (remaining === 0) {
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                       } else {
+                                               range.setStartAfter(el);
+                                               range.setEndAfter(el);
+                                       }
+
+                                       landed = true;
+
+                                       return;
+                               }
+
+                               remaining -= len;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (el.dataset.codeToken !== undefined) {
+                               const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
+
+                               if (isBlock && (pendingBlockBoundary || !first)) {
+                                       pendingBlockBoundary = false;
+
+                                       if (remaining === 0) {
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                               landed = true;
+
+                                               return;
+                                       }
+
+                                       remaining -= 1;
+                               }
+
+                               const len = (el.textContent ?? '').length;
+
+                               if (remaining === 0) {
+                                       range.setStartBefore(el);
+                                       range.setEndBefore(el);
+                                       landed = true;
+
+                                       return;
+                               }
+
+                               if (remaining === len) {
+                                       range.setStartAfter(el);
+                                       range.setEndAfter(el);
+                                       landed = true;
+
+                                       return;
+                               }
+
+                               if (remaining < len) {
+                                       walk(el);
+
+                                       return;
+                               }
+
+                               remaining -= len;
+
+                               if (isBlock) remaining -= 1;
+
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (el.tagName === 'BR') {
+                               const isHatch =
+                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
+
+                               if (isHatch) {
+                                       // Escape hatch: no source length; offset 0 lands before it
+                                       // so text typed there takes its place.
+                                       if (remaining === 0) {
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                               landed = true;
+                                       }
+
+                                       continue;
+                               }
+
+                               if (!el.nextSibling) continue;
+
+                               if (pendingBlockBoundary) {
+                                       pendingBlockBoundary = false;
+
+                                       if (remaining === 0) {
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                               landed = true;
+
+                                               return;
+                                       }
+
+                                       remaining -= 1;
+                               }
+
+                               if (remaining === 0) {
+                                       range.setStartBefore(el);
+                                       range.setEndBefore(el);
+                                       landed = true;
+
+                                       return;
+                               }
+
+                               remaining -= 1;
+                               first = false;
+
+                               continue;
+                       }
+
+                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
+                               if (pendingBlockBoundary || !first) {
+                                       pendingBlockBoundary = false;
+
+                                       if (remaining === 0) {
+                                               // The boundary newline belongs to the previous line.
+                                               range.setStartBefore(el);
+                                               range.setEndBefore(el);
+                                               landed = true;
+
+                                               return;
+                                       }
+
+                                       remaining -= 1;
+                               }
+
+                               walk(el);
+                               first = false;
+
+                               continue;
+                       }
+
+                       const before = remaining;
+
+                       walk(el);
+
+                       if (remaining < before) first = false;
+               }
+       };
+
+       walk(root);
+
+       if (!landed) {
+               const last = root.lastChild;
+
+               if (last && last.nodeName === 'BR') {
+                       range.setStartBefore(last);
+                       range.setEndBefore(last);
+               } else {
+                       range.selectNodeContents(root);
+                       range.collapse(false);
+               }
+       }
+
+       return range;
+}
diff --git a/tools/ui/src/lib/utils/contenteditable-tokenizer.ts b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts
deleted file mode 100644 (file)
index db27139..0000000
+++ /dev/null
@@ -1,1055 +0,0 @@
-/**
- * Maps between the chat-form contenteditable's markdown source and the
- * badge/code/text token stream the DOM is built from. A badge is one
- * opaque source contribution (`[name](file://path)`); its own subtree
- * is never walked, and the caret cannot land inside it, so offsets
- * resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
- * are EDITABLE, unlike badges: they carry the full source segment
- * (backtick fences included) as their text, so their textContent
- * serializes verbatim and source offsets map 1:1 to text offsets.
- *
- * The tokenizer emits a flat DOM (text nodes + badges + code spans),
- * but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
- * shapes). Serialization folds those back into `\n` so the source
- * never diverges from what is on screen; both offset mappers
- * understand the same shapes.
- *
- * The newline separating a fenced block from adjacent content is a
- * SOURCE-level concept, never stored in the DOM: the block is
- * display:block, so a leading `\n` in the following text node would
- * render as a phantom empty line. Serialization synthesizes exactly
- * one `\n` at every block boundary and `buildFragment` strips it from
- * text tokens. A text node's own leading/trailing `\n` next to a
- * block is an ADDITIONAL blank line.
- */
-
-import {
-       decodeFileLinkPath,
-       fileMentionLinkRe,
-       getMentionBadgeIconPaths,
-       getMentionBadgeLabel
-} from './mention-badge';
-import {
-       MENTION_BADGE_CLASSNAME,
-       MENTION_BADGE_ICON_CLASSNAME,
-       MENTION_BADGE_SVG_ATTRIBUTES,
-       SETTINGS_KEYS
-} from '$lib/constants';
-import { ContentEditableTokenKind } from '$lib/enums';
-import { settingsStore } from '$lib/stores/settings.svelte';
-import { toolsStore } from '$lib/stores/tools.svelte';
-import type { ContentEditableToken } from '$lib/types/contenteditable';
-
-// Block wrappers browsers insert for newlines; each folds back into a
-// single `\n` during serialization.
-const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
-// `file://` is required so plain URLs stay as text; `)` terminates only
-// when not followed by whitespace or `[` (adjacent badges keep working).
-const MENTION_BADGE_RE = fileMentionLinkRe('g');
-
-function badgeSourceLength(name: string, path: string): number {
-       if (!name || !path) return 0;
-
-       return `[${name}](file://${path})`.length;
-}
-
-/**
- * Recognize complete code spans. Fenced blocks (triple backticks,
- * optional language, possibly multiline) take priority over inline
- * spans (single backticks, single line, non-empty). Only CLOSED
- * spans match: an unclosed fence stays plain text until the closing
- * backticks land. The match includes the fences so the token's
- * source length equals its rendered text length.
- */
-const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
-
-/**
- * Cheap gate check for `ChatForm`: does the buffer contain a
- * complete code span (inline or fenced)? Used to promote the plain
- * textarea to the contenteditable renderer.
- */
-export function containsCodeSpan(value: string): boolean {
-       CODE_SPAN_RE.lastIndex = 0;
-
-       return CODE_SPAN_RE.test(value);
-}
-
-const CODE_FENCE_RE = /```/g;
-
-/**
- * Is `offset` inside a fenced code block region? Toggle-based: an
- * odd number of ``` fences before the offset means the position
- * sits in block content. Unlike `containsCodeSpan` this also
- * counts the still-OPEN fence while the user is typing a block
- * (no closing ``` yet), so Enter can add a line instead of
- * submitting the message.
- */
-export function isOffsetInCodeBlock(source: string, offset: number): boolean {
-       let inside = false;
-
-       CODE_FENCE_RE.lastIndex = 0;
-
-       let match: RegExpExecArray | null;
-
-       while ((match = CODE_FENCE_RE.exec(source)) !== null) {
-               if (match.index + match[0].length > offset) break;
-
-               inside = !inside;
-       }
-
-       return inside;
-}
-
-/**
- * Tokenize a markdown source value into the segments the
- * contenteditable will render. Code spans are carved out first
- * (their content is literal - a `file://` link inside backticks
- * must NOT render as a badge), then plain text and badges
- * interleave in the remaining gaps. Any whitespace after a badge
- * stays in a plain text token so the round trip is byte-exact.
- */
-export function tokenizeContent(input: string): ContentEditableToken[] {
-       const tokens: ContentEditableToken[] = [];
-
-       let cursor = 0;
-
-       CODE_SPAN_RE.lastIndex = 0;
-
-       let match: RegExpExecArray | null;
-
-       while ((match = CODE_SPAN_RE.exec(input)) !== null) {
-               const start = match.index;
-
-               if (start > cursor) {
-                       pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
-               }
-
-               tokens.push(
-                       match[1] !== undefined
-                               ? { kind: ContentEditableTokenKind.CODE_BLOCK, text: match[1] }
-                               : { kind: ContentEditableTokenKind.INLINE_CODE, text: match[2] }
-               );
-               cursor = start + match[0].length;
-       }
-
-       if (cursor < input.length) {
-               pushTextAndBadgeTokens(input.slice(cursor), tokens);
-       }
-
-       return tokens;
-}
-
-/**
- * Tokenize a code-free segment into text and badge tokens.
- */
-function pushTextAndBadgeTokens(input: string, tokens: ContentEditableToken[]) {
-       let cursor = 0;
-
-       MENTION_BADGE_RE.lastIndex = 0;
-
-       let match: RegExpExecArray | null;
-
-       while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
-               const [whole, name, path] = match;
-               const start = match.index;
-
-               if (start > cursor) {
-                       tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor, start) });
-               }
-
-               tokens.push({ kind: ContentEditableTokenKind.BADGE, name, path });
-               cursor = start + whole.length;
-       }
-
-       if (cursor < input.length) {
-               tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor) });
-       }
-}
-
-function isCodeBlockElement(node: Node | null): node is HTMLElement {
-       return node instanceof HTMLElement && node.dataset.codeToken === 'block';
-}
-
-/**
- * Serialize a contenteditable subtree back to source. `<br>` and block
- * wrappers the browser inserted for newlines fold back into `\n` (a
- * trailing `<br>` is the browser's caret placeholder, not a newline);
- * any other element is transparent. Code spans serialize their
- * textContent verbatim (fences included). One separator `\n` is
- * synthesized at every fenced-block boundary (the DOM never stores
- * it), and a `<br>` adjacent to a code block is an escape hatch, not
- * a newline.
- */
-export function serializeContent(root: HTMLElement): string {
-       let out = '';
-       let pendingBlockBoundary = false;
-
-       const walk = (parent: Node) => {
-               let first = true; // no source-contributing sibling seen yet
-
-               for (const child of Array.from(parent.childNodes)) {
-                       if (child.nodeType === Node.TEXT_NODE) {
-                               const text = child.textContent ?? '';
-
-                               if (text.length > 0) {
-                                       if (pendingBlockBoundary) {
-                                               out += '\n';
-                                               pendingBlockBoundary = false;
-                                       }
-
-                                       out += text;
-                                       first = false;
-                               }
-
-                               continue;
-                       }
-
-                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-                       const el = child as HTMLElement;
-
-                       if (el.dataset.mentionBadge === 'true') {
-                               const name = el.dataset.mentionName ?? '';
-                               const path = el.dataset.mentionPath ?? '';
-
-                               if (name && path) {
-                                       if (pendingBlockBoundary) {
-                                               out += '\n';
-                                               pendingBlockBoundary = false;
-                                       }
-
-                                       out += `[${name}](file://${path})`;
-                                       first = false;
-                               }
-
-                               continue;
-                       }
-
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === 'block';
-
-                               if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
-
-                               pendingBlockBoundary = false;
-                               walk(el);
-                               first = false;
-
-                               if (isBlock) pendingBlockBoundary = true;
-
-                               continue;
-                       }
-
-                       if (el.tagName === 'BR') {
-                               const isHatch =
-                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-                               if (!isHatch && el.nextSibling) {
-                                       if (pendingBlockBoundary) {
-                                               out += '\n';
-                                               pendingBlockBoundary = false;
-                                       }
-
-                                       out += '\n';
-                                       first = false;
-                               }
-
-                               continue;
-                       }
-
-                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
-                               if (pendingBlockBoundary || !first) out += '\n';
-
-                               pendingBlockBoundary = false;
-                               walk(el);
-                               first = false;
-
-                               continue;
-                       }
-
-                       walk(el);
-
-                       if (pendingBlockBoundary) first = false;
-               }
-       };
-
-       walk(root);
-
-       return out;
-}
-
-/**
- * Compare the live DOM's non-text structure against a token stream.
- * Only element contributions are compared (badges by name/path, code
- * spans by kind and source segment): text nodes are owned by the
- * browser between rebuilds, so their split/merge state is irrelevant.
- * A mismatch means token boundaries shifted (a code span was just
- * completed or broken) and the DOM needs a rebuild to restyle.
- */
-export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken[]): boolean {
-       const expected = tokens.filter((token) => token.kind !== ContentEditableTokenKind.TEXT);
-
-       let index = 0;
-
-       const walk = (parent: Node): boolean => {
-               for (const child of Array.from(parent.childNodes)) {
-                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-                       const el = child as HTMLElement;
-                       const isBadge = el.dataset.mentionBadge === 'true';
-                       const isCode = el.dataset.codeToken !== undefined;
-
-                       if (!isBadge && !isCode) {
-                               if (!walk(el)) return false;
-
-                               continue;
-                       }
-
-                       const token = expected[index++];
-
-                       if (!token) return false;
-
-                       if (isBadge) {
-                               if (token.kind !== ContentEditableTokenKind.BADGE) return false;
-
-                               if (token.name !== (el.dataset.mentionName ?? '')) return false;
-
-                               if (token.path !== (el.dataset.mentionPath ?? '')) return false;
-
-                               continue;
-                       }
-
-                       const codeKind: ContentEditableTokenKind =
-                               el.dataset.codeToken === 'block'
-                                       ? ContentEditableTokenKind.CODE_BLOCK
-                                       : ContentEditableTokenKind.INLINE_CODE;
-
-                       if (token.kind !== codeKind) return false;
-
-                       if (
-                               token.kind === ContentEditableTokenKind.INLINE_CODE ||
-                               token.kind === ContentEditableTokenKind.CODE_BLOCK
-                       ) {
-                               if (token.text !== (el.textContent ?? '')) return false;
-                       }
-               }
-
-               return true;
-       };
-
-       return walk(root) && index === expected.length;
-}
-
-/**
- * Plain-text offset of a `Range` in the root; null range (selection
- * lost) falls back to buffer length. Walked against the live DOM (not
- * a clone) so a `<br>` keeps its trailing/not-trailing context. Code
- * spans count their full textContent (fences included) and the caret
- * may land inside them; synthesized block boundaries count one `\n`
- * once the caret is past them.
- */
-export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
-       if (!range) return serializeContent(root).length;
-
-       // A point is at/before the caret iff it falls inside [root start, caret].
-       const pre = range.cloneRange();
-
-       pre.selectNodeContents(root);
-       pre.setEnd(range.endContainer, range.endOffset);
-       const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
-
-       let total = 0;
-       let done = false;
-       // DOM position of a code block's synthesized after-boundary, set
-       // when walking past a block and consumed by the next contributing
-       // sibling (counts one `\n` once the caret is past it).
-       let pendingPoint: { node: Node; index: number } | null = null;
-
-       const walk = (parent: Node) => {
-               let first = true;
-
-               for (const child of Array.from(parent.childNodes)) {
-                       if (done) return;
-
-                       if (child.nodeType === Node.TEXT_NODE) {
-                               const text = child.textContent ?? '';
-
-                               if (text.length === 0) continue;
-
-                               if (pendingPoint) {
-                                       const { index, node } = pendingPoint;
-
-                                       pendingPoint = null;
-
-                                       if (!atOrBeforeCaret(node, index)) {
-                                               done = true;
-
-                                               return;
-                                       }
-
-                                       total += 1;
-                               }
-
-                               if (!atOrBeforeCaret(child, 0)) {
-                                       done = true;
-
-                                       return;
-                               }
-
-                               if (range.endContainer === child) {
-                                       total += range.endOffset;
-                                       done = true;
-
-                                       return;
-                               }
-
-                               total += text.length;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-                       const el = child as HTMLElement;
-                       const parentNode = el.parentNode as Node;
-                       const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
-
-                       if (pendingPoint) {
-                               const { index, node } = pendingPoint;
-
-                               pendingPoint = null;
-
-                               if (!atOrBeforeCaret(node, index)) {
-                                       done = true;
-
-                                       return;
-                               }
-
-                               total += 1;
-                       }
-
-                       if (el.dataset.mentionBadge === 'true') {
-                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
-
-                               if (len === 0) continue;
-
-                               if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
-                                       done = true;
-
-                                       return;
-                               }
-
-                               total += len;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === 'block';
-
-                               if (isBlock && !first) {
-                                       if (!atOrBeforeCaret(el, 0)) {
-                                               done = true;
-
-                                               return;
-                                       }
-
-                                       total += 1;
-                               }
-
-                               walk(el);
-                               first = false;
-
-                               if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
-
-                               continue;
-                       }
-
-                       if (el.tagName === 'BR') {
-                               const isHatch =
-                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-                               if (isHatch || !el.nextSibling) continue;
-
-                               if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
-                                       done = true;
-
-                                       return;
-                               }
-
-                               total += 1;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
-                               if (!first) {
-                                       if (!atOrBeforeCaret(el, 0)) {
-                                               done = true;
-
-                                               return;
-                                       }
-
-                                       total += 1;
-                               }
-
-                               walk(el);
-                               first = false;
-
-                               continue;
-                       }
-
-                       const before = total;
-
-                       walk(el);
-
-                       if (total > before) first = false;
-               }
-       };
-
-       walk(root);
-
-       return total;
-}
-
-/**
- * Materialize a token stream into a DOM subtree: text nodes for text
- * tokens, `<span data-mention-badge="true">` elements for badges,
- * `<code data-code-token>` elements for code spans. The badge's class
- * string + inline SVG are shared with the rehype plugin via
- * `$lib/constants`.
- */
-export function buildFragment(tokens: ContentEditableToken[]): DocumentFragment {
-       const fragment = document.createDocumentFragment();
-
-       for (let index = 0; index < tokens.length; index++) {
-               const token = tokens[index];
-
-               if (token.kind === ContentEditableTokenKind.TEXT) {
-                       let text = token.text;
-
-                       // The separator \n at a fenced-block boundary is synthesized
-                       // at serialization time; keeping it in the DOM would render a
-                       // phantom empty line next to the block.
-                       if (
-                               tokens[index - 1]?.kind === ContentEditableTokenKind.CODE_BLOCK &&
-                               text.startsWith('\n')
-                       ) {
-                               text = text.slice(1);
-                       }
-
-                       if (tokens[index + 1]?.kind === ContentEditableTokenKind.CODE_BLOCK && text.endsWith('\n')) {
-                               text = text.slice(0, -1);
-                       }
-
-                       if (text.length === 0) continue;
-
-                       fragment.appendChild(document.createTextNode(text));
-
-                       continue;
-               }
-
-               if (
-                       token.kind === ContentEditableTokenKind.INLINE_CODE ||
-                       token.kind === ContentEditableTokenKind.CODE_BLOCK
-               ) {
-                       const code = document.createElement('code');
-
-                       code.dataset.codeToken =
-                               token.kind === ContentEditableTokenKind.CODE_BLOCK ? 'block' : 'inline';
-                       code.textContent = token.text;
-                       fragment.appendChild(code);
-
-                       continue;
-               }
-
-               // A leading badge gets an empty text node prepended: without a real
-               // text position at the buffer start, the spot before the badge is
-               // unreachable via keyboard (ArrowLeft/Home).
-               if (!fragment.lastChild) {
-                       fragment.appendChild(document.createTextNode(''));
-               }
-
-               const badge = document.createElement('span');
-
-               badge.dataset.mentionBadge = 'true';
-               badge.dataset.mentionName = token.name;
-               badge.dataset.mentionPath = token.path;
-               badge.title = decodeFileLinkPath(token.path);
-               badge.className = MENTION_BADGE_CLASSNAME;
-               badge.contentEditable = 'false';
-
-               const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
-
-               for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
-                       svg.setAttribute(attr, value);
-               }
-               for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
-                       svg.classList.add(cls);
-               }
-
-               for (const d of getMentionBadgeIconPaths(token.path)) {
-                       const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
-
-                       path.setAttribute('d', d);
-                       svg.appendChild(path);
-               }
-
-               const label = document.createElement('span');
-
-               label.classList.add('shrink-0', 'truncate');
-               label.textContent = getMentionBadgeLabel(
-                       token.name,
-                       decodeFileLinkPath(token.path),
-                       settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
-                       toolsStore.serverHome
-               );
-
-               badge.appendChild(svg);
-               badge.appendChild(label);
-               fragment.appendChild(badge);
-       }
-
-       return fragment;
-}
-
-// A sibling provides a reachable caret line when it is an element
-// (badge, another block, an existing hatch) or a non-empty text node.
-function hasLineBeside(node: Node | null): boolean {
-       if (!node) return false;
-
-       if (node.nodeType === Node.ELEMENT_NODE) return true;
-
-       return (node.textContent ?? '') !== '';
-}
-
-/**
- * A code block at the END of the buffer needs an editable line after
- * it: without one the caret cannot leave the block with
- * ArrowDown/ArrowRight. A trailing `<br>` provides that line while
- * staying transparent to serialization (skipped as a hatch), and is
- * removed again once real content takes its place.
- *
- * No hatch is added BEFORE a leading block: the empty line above it
- * is transient and managed by the component (created when the caret
- * arrows onto it, removed when the caret leaves). A transient
- * leading hatch found here is kept; the browser's lone placeholder
- * `<br>` in an empty root is left untouched.
- */
-export function syncCodeBlockHatches(root: HTMLElement) {
-       for (const child of Array.from(root.childNodes)) {
-               if (child.nodeName !== 'BR') continue;
-
-               const isPlaceholder = root.childNodes.length === 1;
-               const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
-               const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
-
-               // A hatch goes stale once real content takes over its line:
-               // content before a leading hatch, content after a trailing one,
-               // or a text node after the block already providing the line.
-               // A `<br>` with no code block around is a real newline (browser
-               // Shift+Enter shape) and stays.
-               let prevElement = child.previousSibling;
-
-               while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
-                       prevElement = prevElement.previousSibling;
-               }
-               const nearBlock =
-                       isCodeBlockElement(child.nextSibling) ||
-                       isCodeBlockElement(child.previousSibling) ||
-                       isCodeBlockElement(prevElement);
-
-               if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
-                       child.remove();
-               }
-       }
-
-       for (const child of Array.from(root.childNodes)) {
-               if (!isCodeBlockElement(child)) continue;
-
-               if (!hasLineBeside(child.nextSibling)) {
-                       child.after(document.createElement('br'));
-               }
-       }
-}
-
-/**
- * Strip the separator and artificial newlines from an all-newline text
- * node directly after a fenced block. Chromium's line break at the
- * buffer end inserts an extra artificial `\n` so the new line has
- * height, and the first `\n` after a block doubles as the fence's
- * separator line (synthesized at serialization time). Removing both
- * makes Shift+Enter after a block land the caret on the line directly
- * below the block, like a plain textarea would.
- *
- * Only all-newline text nodes are touched: a node with real content
- * carries intentional blank lines and is left alone. Returns true when
- * the DOM changed.
- */
-export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
-       let changed = false;
-
-       for (const child of Array.from(root.childNodes)) {
-               if (child.nodeType !== Node.TEXT_NODE) continue;
-
-               if (!isCodeBlockElement(child.previousSibling)) continue;
-
-               let text = child.textContent ?? '';
-
-               if (!/^\n{2,}$/.test(text)) continue;
-
-               text = text.slice(1);
-
-               const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
-
-               if (atBufferEnd) {
-                       text = text.slice(0, -1);
-               }
-
-               child.textContent = text;
-               changed = true;
-       }
-
-       return changed;
-}
-
-const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
-
-/**
- * Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
- * when the jump crosses no badge and native word movement should handle
- * it. Badge spans are masked to word characters, so a badge counts as
- * exactly one word.
- */
-export function badgeAwareWordJump(
-       source: string,
-       offset: number,
-       direction: 'forward' | 'backward'
-): number | null {
-       let masked = '';
-
-       const badgeSpans: Array<[number, number]> = [];
-
-       for (const token of tokenizeContent(source)) {
-               const len =
-                       token.kind === ContentEditableTokenKind.BADGE
-                               ? badgeSourceLength(token.name, token.path)
-                               : token.text.length;
-
-               if (token.kind === ContentEditableTokenKind.BADGE)
-                       badgeSpans.push([masked.length, masked.length + len]);
-
-               masked += token.kind === ContentEditableTokenKind.BADGE ? 'a'.repeat(len) : token.text;
-       }
-
-       if (badgeSpans.length === 0) return null;
-
-       const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
-       const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
-       const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
-       const n = masked.length;
-
-       let i = offset;
-
-       if (direction === 'forward') {
-               // Entering a badge completes the word phase at the badge's end edge.
-               if (!(i < n && isWord(i))) {
-                       while (i < n && !isWord(i)) i++;
-               }
-
-               while (i < n && isWord(i)) {
-                       const span = spanStartingAt(i);
-
-                       if (span) {
-                               i = span[1];
-
-                               break;
-                       }
-
-                       i++;
-               }
-       } else {
-               if (!(i > 0 && isWord(i - 1))) {
-                       while (i > 0 && !isWord(i - 1)) i--;
-               }
-
-               while (i > 0 && isWord(i - 1)) {
-                       const span = spanEndingAt(i);
-
-                       if (span) {
-                               i = span[0];
-
-                               break;
-                       }
-
-                       i--;
-               }
-       }
-
-       if (i === offset) return null;
-
-       const lo = Math.min(offset, i);
-       const hi = Math.max(offset, i);
-
-       return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
-}
-
-/**
- * 0 when `caret` sits exactly at a leading badge's end edge, null
- * otherwise. Plain ArrowLeft there has no native previous position, so
- * the host snaps the caret to the buffer start manually.
- */
-export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
-       const [first] = tokenizeContent(source);
-
-       if (!first || first.kind !== ContentEditableTokenKind.BADGE) return null;
-
-       return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
-}
-
-/**
- * Translate a plain-text offset into a degenerate `Range` at that
- * position in the DOM; out-of-range offsets clamp to buffer end (before
- * a trailing escape hatch, not after it). Zero offset lands BEFORE a
- * badge or code span, and an offset exactly at a code span's end lands
- * AFTER it, so typing at a code span's edge extends the surrounding
- * text. Interior code-span offsets land in the element's text.
- * Understands the same block/`<br>` newline shapes as
- * `serializeContent`.
- */
-export function textOffsetToRange(root: HTMLElement, offset: number): Range {
-       const range = document.createRange();
-
-       let remaining = offset;
-       let landed = false;
-       let pendingBlockBoundary = false;
-
-       const land = (node: Node, nodeOffset: number) => {
-               range.setStart(node, nodeOffset);
-               range.setEnd(node, nodeOffset);
-               landed = true;
-       };
-       const walk = (parent: Node) => {
-               let first = true;
-
-               for (const child of Array.from(parent.childNodes)) {
-                       if (landed) return;
-
-                       if (child.nodeType === Node.TEXT_NODE) {
-                               const text = child.textContent ?? '';
-
-                               if (text.length === 0) continue;
-
-                               if (pendingBlockBoundary) {
-                                       // The synthesized separator maps to the near edge of the
-                                       // content that follows the block.
-                                       pendingBlockBoundary = false;
-
-                                       if (remaining === 0) {
-                                               land(child, 0);
-
-                                               return;
-                                       }
-
-                                       remaining -= 1;
-                               }
-
-                               if (remaining <= text.length) {
-                                       land(child, remaining);
-
-                                       return;
-                               }
-
-                               remaining -= text.length;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (child.nodeType !== Node.ELEMENT_NODE) continue;
-
-                       const el = child as HTMLElement;
-
-                       if (el.dataset.mentionBadge === 'true') {
-                               const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
-
-                               if (len === 0) continue;
-
-                               if (pendingBlockBoundary) {
-                                       pendingBlockBoundary = false;
-
-                                       if (remaining === 0) {
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                               landed = true;
-
-                                               return;
-                                       }
-
-                                       remaining -= 1;
-                               }
-
-                               if (remaining <= len) {
-                                       if (remaining === 0) {
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                       } else {
-                                               range.setStartAfter(el);
-                                               range.setEndAfter(el);
-                                       }
-
-                                       landed = true;
-
-                                       return;
-                               }
-
-                               remaining -= len;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (el.dataset.codeToken !== undefined) {
-                               const isBlock = el.dataset.codeToken === 'block';
-
-                               if (isBlock && (pendingBlockBoundary || !first)) {
-                                       pendingBlockBoundary = false;
-
-                                       if (remaining === 0) {
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                               landed = true;
-
-                                               return;
-                                       }
-
-                                       remaining -= 1;
-                               }
-
-                               const len = (el.textContent ?? '').length;
-
-                               if (remaining === 0) {
-                                       range.setStartBefore(el);
-                                       range.setEndBefore(el);
-                                       landed = true;
-
-                                       return;
-                               }
-
-                               if (remaining === len) {
-                                       range.setStartAfter(el);
-                                       range.setEndAfter(el);
-                                       landed = true;
-
-                                       return;
-                               }
-
-                               if (remaining < len) {
-                                       walk(el);
-
-                                       return;
-                               }
-
-                               remaining -= len;
-
-                               if (isBlock) remaining -= 1;
-
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (el.tagName === 'BR') {
-                               const isHatch =
-                                       isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
-
-                               if (isHatch) {
-                                       // Escape hatch: no source length; offset 0 lands before it
-                                       // so text typed there takes its place.
-                                       if (remaining === 0) {
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                               landed = true;
-                                       }
-
-                                       continue;
-                               }
-
-                               if (!el.nextSibling) continue;
-
-                               if (pendingBlockBoundary) {
-                                       pendingBlockBoundary = false;
-
-                                       if (remaining === 0) {
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                               landed = true;
-
-                                               return;
-                                       }
-
-                                       remaining -= 1;
-                               }
-
-                               if (remaining === 0) {
-                                       range.setStartBefore(el);
-                                       range.setEndBefore(el);
-                                       landed = true;
-
-                                       return;
-                               }
-
-                               remaining -= 1;
-                               first = false;
-
-                               continue;
-                       }
-
-                       if (BLOCK_TAG_NAMES.has(el.tagName)) {
-                               if (pendingBlockBoundary || !first) {
-                                       pendingBlockBoundary = false;
-
-                                       if (remaining === 0) {
-                                               // The boundary newline belongs to the previous line.
-                                               range.setStartBefore(el);
-                                               range.setEndBefore(el);
-                                               landed = true;
-
-                                               return;
-                                       }
-
-                                       remaining -= 1;
-                               }
-
-                               walk(el);
-                               first = false;
-
-                               continue;
-                       }
-
-                       const before = remaining;
-
-                       walk(el);
-
-                       if (remaining < before) first = false;
-               }
-       };
-
-       walk(root);
-
-       if (!landed) {
-               const last = root.lastChild;
-
-               if (last && last.nodeName === 'BR') {
-                       range.setStartBefore(last);
-                       range.setEndBefore(last);
-               } else {
-                       range.selectNodeContents(root);
-                       range.collapse(false);
-               }
-       }
-
-       return range;
-}
index 178dae7a48695b75e9ed72aeee6ff5ae63ad189a..abe21427242510c59882fb7f322169aa799094a7 100644 (file)
@@ -221,7 +221,7 @@ export {
        textOffsetToRange,
        badgeAwareWordJump,
        leadingBadgeEdgeOffset
-} from './contenteditable-tokenizer';
+} from './chat-form-input-rich-tokenizer';
 
 // Source-space undo/redo history for the chat-form contenteditable
 export { SourceHistory, type SourceHistoryEntry } from './source-history';
diff --git a/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts
deleted file mode 100644 (file)
index 0b719c4..0000000
+++ /dev/null
@@ -1,179 +0,0 @@
-// Guards the newline contract of the chat-form contenteditable: browsers
-// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
-// serialization must fold those back into `\n` so the emitted value never
-// diverges from what is on screen.
-
-import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
-import { tick } from 'svelte';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'see [docs](file:///a/b) here';
-
-function editableIn(container: HTMLElement): HTMLElement {
-       const el = container.querySelector('[role="textbox"]');
-
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
-
-       return el;
-}
-
-function fireInput(root: HTMLElement) {
-       root.dispatchEvent(new InputEvent('input', { bubbles: true }));
-}
-
-function setCaret(node: Node, offset: number) {
-       const range = document.createRange();
-
-       range.setStart(node, offset);
-       range.setEnd(node, offset);
-       const selection = window.getSelection();
-
-       if (!selection) throw new Error('no selection');
-
-       selection.removeAllRanges();
-       selection.addRange(range);
-}
-
-describe('ChatFormContentEditable browser newline shapes', () => {
-       it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-               const div = document.createElement('div');
-
-               div.textContent = 'second line';
-               root.appendChild(div);
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
-       });
-
-       it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-               const first = document.createElement('div');
-
-               while (root.firstChild) first.appendChild(root.firstChild);
-               const second = document.createElement('div');
-
-               second.textContent = 'second line';
-               root.appendChild(first);
-               root.appendChild(second);
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
-       });
-
-       it('serializes a <br> as a newline', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'here' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               root.appendChild(document.createElement('br'));
-               root.appendChild(document.createTextNode('second line'));
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe('here\nsecond line');
-       });
-
-       it('ignores a trailing <br> (browser caret placeholder)', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               root.appendChild(document.createElement('br'));
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe('abc');
-       });
-
-       it('serializes one newline per empty-line <div><br></div>', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               for (let i = 0; i < 2; i++) {
-                       const div = document.createElement('div');
-
-                       div.appendChild(document.createElement('br'));
-                       root.appendChild(div);
-               }
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe('abc\n\n');
-       });
-
-       it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-               const div = document.createElement('div');
-
-               div.appendChild(document.createElement('br'));
-               root.replaceChildren(div);
-               fireInput(root);
-               await tick();
-
-               expect(screen.component.getValue()).toBe('');
-               expect(root.dataset.empty).toBe('true');
-       });
-
-       it('maps the caret across block boundaries in both directions', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc\ndef' });
-
-               await tick();
-
-               // Rebuild into the Chromium block shape; the source is unchanged,
-               // so no re-render fires.
-               const root = editableIn(screen.container);
-               const div = document.createElement('div');
-
-               div.textContent = 'def';
-               root.replaceChildren(document.createTextNode('abc'), div);
-               fireInput(root);
-               await tick();
-               expect(screen.component.getValue()).toBe('abc\ndef');
-
-               const divText = div.firstChild;
-
-               if (!divText) throw new Error('div text missing');
-
-               setCaret(divText, 2);
-               expect(screen.component.getCaretOffset()).toBe(6);
-
-               screen.component.setCaretOffset(6);
-               const selection = window.getSelection();
-
-               expect(selection?.anchorNode).toBe(divText);
-               expect(selection?.anchorOffset).toBe(2);
-
-               // The boundary newline itself: offset 3 is the end of "abc", offset
-               // 4 the start of the "def" line.
-               screen.component.setCaretOffset(4);
-               expect(window.getSelection()?.anchorNode).toBe(divText);
-               expect(window.getSelection()?.anchorOffset).toBe(0);
-
-               screen.component.setCaretOffset(3);
-               expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
-               expect(window.getSelection()?.anchorOffset).toBe(3);
-       });
-});
diff --git a/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts
deleted file mode 100644 (file)
index 7ed01fd..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-// Guards the editing-key contract of the chat-form contenteditable:
-// undo/redo is replayed from source snapshots (the token rebuilds destroy
-// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
-// keyboard trap), matching the plain textarea.
-
-import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte';
-import { tick } from 'svelte';
-import { describe, expect, it } from 'vitest';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'see [docs](file:///a/b)';
-
-function editableIn(container: HTMLElement): HTMLElement {
-       const el = container.querySelector('[role="textbox"]');
-
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
-
-       return el;
-}
-
-function type(root: HTMLElement, text: string, inputType = 'insertText') {
-       root.appendChild(document.createTextNode(text));
-       root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
-}
-
-function keydown(root: HTMLElement, init: KeyboardEventInit) {
-       const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
-
-       root.dispatchEvent(event);
-
-       return event;
-}
-
-describe('ChatFormContentEditable undo/redo', () => {
-       it('undoes and redoes an edit across a badge-containing buffer', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               type(root, ' more');
-               await tick();
-               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-
-               const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
-
-               await tick();
-               expect(undoEvent.defaultPrevented).toBe(true);
-               expect(screen.component.getValue()).toBe(SOURCE);
-
-               const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
-
-               await tick();
-               expect(redoEvent.defaultPrevented).toBe(true);
-               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-       });
-
-       it('redoes with Ctrl+Y as well', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               type(root, ' more');
-               await tick();
-               keydown(root, { key: 'z', metaKey: true });
-               await tick();
-               expect(screen.component.getValue()).toBe(SOURCE);
-
-               keydown(root, { ctrlKey: true, key: 'y' });
-               await tick();
-               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
-       });
-
-       it('coalesces a typing burst into one undo step', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               type(root, 'd');
-               type(root, 'e');
-               await tick();
-               expect(screen.component.getValue()).toBe('abcde');
-
-               keydown(root, { ctrlKey: true, key: 'z' });
-               await tick();
-               expect(screen.component.getValue()).toBe('abc');
-       });
-
-       it('keeps a newline as its own undo step', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               type(root, 'd');
-               type(root, '\n', 'insertLineBreak');
-               await tick();
-               expect(screen.component.getValue()).toBe('abcd\n');
-
-               keydown(root, { ctrlKey: true, key: 'z' });
-               await tick();
-               expect(screen.component.getValue()).toBe('abcd');
-
-               keydown(root, { ctrlKey: true, key: 'z' });
-               await tick();
-               expect(screen.component.getValue()).toBe('abc');
-       });
-
-       it('is a no-op when there is nothing to undo', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-               const event = keydown(root, { ctrlKey: true, key: 'z' });
-
-               await tick();
-
-               expect(event.defaultPrevented).toBe(true);
-               expect(screen.component.getValue()).toBe('abc');
-       });
-
-       it('abandons the redo branch after a fresh edit', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: 'abc' });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-
-               type(root, 'd');
-               await tick();
-               keydown(root, { ctrlKey: true, key: 'z' });
-               await tick();
-               expect(screen.component.getValue()).toBe('abc');
-
-               type(root, 'e');
-               await tick();
-               keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
-               await tick();
-               expect(screen.component.getValue()).toBe('abce');
-       });
-});
-
-describe('ChatFormContentEditable Tab key', () => {
-       it('does not trap Tab (focus can leave the editable)', async () => {
-               const screen = render(ChatFormContentEditableHarness, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(screen.container);
-               const event = keydown(root, { key: 'Tab' });
-
-               expect(event.defaultPrevented).toBe(false);
-       });
-});
diff --git a/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts
deleted file mode 100644 (file)
index 0767fa7..0000000
+++ /dev/null
@@ -1,804 +0,0 @@
-// Guards the clipboard contract of the chat-form contenteditable:
-// copy/cut expose the markdown SOURCE of the selection (each badge
-// contributes its full `[name](file://...)` link) and pasting such
-// markdown re-renders the badges.
-
-import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
-import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
-import { tick } from 'svelte';
-import { describe, expect, it, vi } from 'vitest';
-import { userEvent } from 'vitest/browser';
-import { render } from 'vitest-browser-svelte';
-
-const SOURCE = 'hello [docs](file:///a/b) world';
-const BADGE_SELECTOR = '[data-mention-badge="true"]';
-
-function editableIn(container: HTMLElement): HTMLElement {
-       const el = container.querySelector('[role="textbox"]');
-
-       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
-
-       return el;
-}
-
-function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
-       const range = document.createRange();
-
-       place(range, root);
-       const selection = window.getSelection();
-
-       if (!selection) throw new Error('no selection');
-
-       selection.removeAllRanges();
-       selection.addRange(range);
-}
-
-function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
-       const data = new DataTransfer();
-
-       if (text) data.setData('text/plain', text);
-
-       const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
-
-       return { data, event };
-}
-
-describe('ChatFormContentEditable clipboard', () => {
-       it('copy exposes the markdown source of the selection', async () => {
-               const { container } = render(ChatFormContentEditable, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               setSelection(root, (range) => range.selectNodeContents(root));
-
-               const { data, event } = clipboardEvent('copy');
-
-               root.dispatchEvent(event);
-
-               expect(event.defaultPrevented).toBe(true);
-               expect(data.getData('text/plain')).toBe(SOURCE);
-       });
-
-       it('cut exposes the markdown source and removes the slice', async () => {
-               const { container } = render(ChatFormContentEditable, { value: SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               setSelection(root, (range) => {
-                       const badge = root.querySelector(BADGE_SELECTOR);
-
-                       if (!badge) throw new Error('badge not rendered');
-
-                       range.setStartBefore(badge);
-                       range.setEndAfter(badge);
-               });
-
-               const { data, event } = clipboardEvent('cut');
-
-               root.dispatchEvent(event);
-
-               expect(event.defaultPrevented).toBe(true);
-               expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
-               expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
-               expect(root.textContent).toBe('hello  world');
-       });
-
-       it('paste of markdown mention links re-renders badges', async () => {
-               const { container } = render(ChatFormContentEditable, { value: 'hello ' });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       range.selectNodeContents(root);
-                       range.collapse(false);
-               });
-
-               const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
-
-               root.dispatchEvent(event);
-               await tick();
-
-               expect(event.defaultPrevented).toBe(true);
-               const badge = root.querySelector(BADGE_SELECTOR);
-
-               expect(badge).not.toBeNull();
-               expect(badge!.getAttribute('data-mention-name')).toBe('docs');
-               expect(root.textContent).toContain('world');
-       });
-
-       it('paste without mention links keeps the DOM untouched', async () => {
-               const { container } = render(ChatFormContentEditable, { value: 'hello ' });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       range.selectNodeContents(root);
-                       range.collapse(false);
-               });
-               const firstChild = root.firstChild;
-               const { event } = clipboardEvent('paste', 'plain text');
-
-               root.dispatchEvent(event);
-               await tick();
-
-               expect(event.defaultPrevented).toBe(true);
-               expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
-               // no rebuild: the live text node is the same instance
-               expect(root.firstChild).toBe(firstChild);
-       });
-});
-
-describe('ChatFormContentEditable code spans', () => {
-       it('renders inline code from the initial value', async () => {
-               const { container } = render(ChatFormContentEditable, { value: 'run `npm test` now' });
-
-               await tick();
-
-               const root = editableIn(container);
-               const code = root.querySelector('code[data-code-token="inline"]');
-
-               expect(code).not.toBeNull();
-               expect(code!.textContent).toBe('`npm test`');
-       });
-
-       it('renders a fenced code block with a language', async () => {
-               const source = 'before\n```js\nconst a = 1;\n```\nafter';
-               const { container } = render(ChatFormContentEditable, { value: source });
-
-               await tick();
-
-               const root = editableIn(container);
-               const code = root.querySelector('code[data-code-token="block"]');
-
-               expect(code).not.toBeNull();
-               expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
-       });
-
-       it('copy exposes the markdown source of a selection spanning code', async () => {
-               const source = 'run `npm test` now';
-               const { container } = render(ChatFormContentEditable, { value: source });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               setSelection(root, (range) => range.selectNodeContents(root));
-
-               const { data, event } = clipboardEvent('copy');
-
-               root.dispatchEvent(event);
-
-               expect(event.defaultPrevented).toBe(true);
-               expect(data.getData('text/plain')).toBe(source);
-       });
-
-       it('paste of a code span renders the styled element', async () => {
-               const { container } = render(ChatFormContentEditable, { value: 'run ' });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       range.selectNodeContents(root);
-                       range.collapse(false);
-               });
-
-               const { event } = clipboardEvent('paste', '`npm test` now');
-
-               root.dispatchEvent(event);
-               await tick();
-
-               expect(event.defaultPrevented).toBe(true);
-               const code = root.querySelector('code[data-code-token="inline"]');
-
-               expect(code).not.toBeNull();
-               expect(code!.textContent).toBe('`npm test`');
-               expect(root.textContent).toContain('now');
-       });
-
-       it('highlights a fenced block content and stays byte-exact', async () => {
-               const source = '```js\nconst a = 1;\n```';
-               const { container } = render(ChatFormContentEditable, { value: source });
-
-               await tick();
-
-               const root = editableIn(container);
-               const code = root.querySelector('code[data-code-token="block"]');
-
-               expect(code).not.toBeNull();
-               expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
-               expect(code!.textContent).toBe(source);
-       });
-
-       it('does not highlight inline code', async () => {
-               const { container } = render(ChatFormContentEditable, { value: 'run `const` now' });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               expect(root.querySelector('[class*="hljs-"]')).toBeNull();
-       });
-});
-
-describe('ChatFormContentEditable code block escape hatches', () => {
-       const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
-       const BLOCK_SELECTOR = 'code[data-code-token="block"]';
-
-       function blockIn(root: HTMLElement): HTMLElement {
-               const el = root.querySelector(BLOCK_SELECTOR);
-
-               if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
-
-               return el;
-       }
-
-       // Caret at the very start/end of the block's text (across highlight spans)
-       function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
-               const code = blockIn(root);
-               const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
-
-               let target: Node | null = null;
-
-               for (let n = walker.nextNode(); n; n = walker.nextNode()) {
-                       target = where === 'start' ? (target ?? n) : n;
-               }
-
-               if (!target) throw new Error('no text inside code block');
-
-               setSelection(root, (range) => {
-                       range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
-                       range.collapse(true);
-               });
-       }
-
-       function caretContainer(): Node {
-               const selection = window.getSelection();
-
-               if (!selection || selection.rangeCount === 0) throw new Error('no selection');
-
-               return selection.getRangeAt(0).startContainer;
-       }
-
-       it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               // no permanent empty line above a leading block
-               expect(root.firstChild).toBe(blockIn(root));
-               expect(root.lastChild?.nodeName).toBe('BR');
-
-               setSelection(root, (range) => range.selectNodeContents(root));
-               const { data, event } = clipboardEvent('copy');
-
-               root.dispatchEvent(event);
-
-               expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
-       });
-
-       it('escapes a trailing code block with ArrowDown and types after it', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               await userEvent.keyboard('{ArrowDown}');
-               expect(blockIn(root).contains(caretContainer())).toBe(false);
-
-               await userEvent.keyboard('x');
-               await tick();
-
-               expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
-               // the DOM holds no separator newline (it would render as a
-               // phantom empty line); serialization synthesizes it so the
-               // markdown source keeps the text below the block
-               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-               // the stale trailing hatch is removed once real text follows the block
-               expect(root.lastChild?.nodeName).not.toBe('BR');
-       });
-
-       it('escapes a leading code block with ArrowUp and types before it', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'start');
-
-               await userEvent.keyboard('{ArrowUp}');
-               expect(blockIn(root).contains(caretContainer())).toBe(false);
-               // the transient hatch line exists while the caret sits on it
-               expect(root.firstChild?.nodeName).toBe('BR');
-
-               await userEvent.keyboard('y');
-               await tick();
-
-               expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
-               expect(root.textContent).toBe('y' + BLOCK_SOURCE);
-               expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
-               // the typed text consumed the hatch
-               expect(root.firstChild?.nodeName).not.toBe('BR');
-       });
-
-       it('escapes a leading code block with ArrowLeft from its first character', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'start');
-
-               await userEvent.keyboard('{ArrowLeft}');
-               expect(blockIn(root).contains(caretContainer())).toBe(false);
-               expect(root.firstChild?.nodeName).toBe('BR');
-       });
-
-       it('removes the transient leading hatch when the caret moves back into the block', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'start');
-
-               await userEvent.keyboard('{ArrowUp}');
-               expect(root.firstChild?.nodeName).toBe('BR');
-
-               await userEvent.keyboard('{ArrowDown}');
-               await tick();
-
-               expect(blockIn(root).contains(caretContainer())).toBe(true);
-               expect(root.firstChild).toBe(blockIn(root));
-       });
-
-       it('extends the selection out of the block with Shift+ArrowDown', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
-
-               const selection = window.getSelection();
-
-               expect(selection).not.toBeNull();
-               expect(selection!.isCollapsed).toBe(false);
-               expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
-       });
-
-       it('line-separates text typed right after the closing fence', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               // no arrow keys: the caret sits at the block's end edge, where the
-               // post-rebuild restore lands it, and the typed text renders on the
-               // line below the block
-               await userEvent.keyboard('x');
-               await tick();
-
-               // the text stays on the caret's line in the DOM (no phantom empty
-               // line); the source gets the separator newline
-               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-       });
-
-       it('does not double the newline when Shift+Enter already added one', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
-               await userEvent.keyboard('x');
-               await tick();
-
-               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
-       });
-
-       it('moves a caret stuck before the inserted newline onto the new line', async () => {
-               const { container } = render(ChatFormContentEditable, {
-                       value: BLOCK_SOURCE + '\ntext after the code block'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-
-               // post-break DOM some browsers produce: the inserted newline plus
-               // the artificial trailing one, with the caret stuck BEFORE the
-               // inserted one (visually at the end of the old line)
-               root.appendChild(document.createTextNode('\n'));
-               root.appendChild(document.createTextNode('\n'));
-               setSelection(root, (range) => {
-                       range.setStart(root.childNodes[2], 0);
-                       range.collapse(true);
-               });
-
-               root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
-               await tick();
-
-               const selection = window.getSelection();
-
-               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-                       (BLOCK_SOURCE + '\ntext after the code block\n').length
-               );
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-       });
-
-       it('appends the artificial trailing newline when the browser did not add one', async () => {
-               const { container } = render(ChatFormContentEditable, {
-                       value: BLOCK_SOURCE + '\ntext after the code block'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-
-               // post-break DOM some browsers produce: a lone trailing \n (or a
-               // <br> the hatch sync strips). Collapsed by the renderer, so the
-               // caret looks stuck on the old line and the next typed character
-               // would consume the newline.
-               root.appendChild(document.createTextNode('\n'));
-               setSelection(root, (range) => {
-                       range.setStart(root.childNodes[2], 1);
-                       range.collapse(true);
-               });
-
-               root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
-               await tick();
-
-               const selection = window.getSelection();
-
-               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-                       (BLOCK_SOURCE + '\ntext after the code block\n').length
-               );
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-       });
-
-       it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
-               const { container } = render(ChatFormContentEditable, {
-                       value: BLOCK_SOURCE + '\ntext after the code block'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       const text = root.childNodes[1];
-
-                       range.setStart(text, (text.textContent ?? '').length);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
-               await tick();
-
-               const selection = window.getSelection();
-
-               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
-                       (BLOCK_SOURCE + '\ntext after the code block\n').length
-               );
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
-
-               // the next typed character lands on the new line
-               await userEvent.keyboard('x');
-               await tick();
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
-       });
-
-       it('lets Backspace at the text start move into the block without a source fight', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               await userEvent.keyboard('{ArrowDown}');
-               await userEvent.keyboard('create');
-               await tick();
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
-
-               // Backspace at the start of the text line: the separator newline
-               // is structural (synthesized while text follows the block), so
-               // the caret just moves to the block's edge - nothing is re-added
-               await userEvent.keyboard('{Home}');
-               await userEvent.keyboard('{Backspace}');
-               await tick();
-
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
-               expect(caretContainer()).toBe(root);
-       });
-
-       it('lets forward Delete eat the text after a block normally', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               placeCaretInBlock(root, 'end');
-
-               await userEvent.keyboard('{ArrowDown}');
-               await userEvent.keyboard('create');
-               await tick();
-
-               await userEvent.keyboard('{Home}');
-               await userEvent.keyboard('{Delete}');
-               await tick();
-
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
-       });
-
-       it('renders text after a block without a phantom empty line', async () => {
-               const { container } = render(ChatFormContentEditable, {
-                       value: BLOCK_SOURCE + '\nhello'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
-       });
-
-       it('keeps an intentional blank line after a block out of the separator', async () => {
-               const { container } = render(ChatFormContentEditable, {
-                       value: BLOCK_SOURCE + '\n\nhello'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
-       });
-
-       it('re-highlights while typing inside a block and keeps the caret', async () => {
-               const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-
-               // caret at the start of the block content (after the opening fence)
-               setSelection(root, (range) => {
-                       const target = textOffsetToRange(root, 6);
-
-                       range.setStart(target.startContainer, target.startOffset);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('x');
-               await tick();
-
-               const code = blockIn(root);
-
-               expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
-               expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
-               expect(code.querySelector('.hljs-number')).not.toBeNull();
-
-               const selection = window.getSelection();
-
-               expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
-               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
-       });
-});
-
-describe('ChatFormContentEditable Enter in code blocks', () => {
-       const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
-
-       it('adds a line instead of submitting on plain Enter inside a block', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: BLOCK_SOURCE
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-
-               // caret at the start of the block content (after the opening fence)
-               setSelection(root, (range) => {
-                       const target = textOffsetToRange(root, 6);
-
-                       range.setStart(target.startContainer, target.startOffset);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Enter}');
-               await tick();
-
-               // consumed locally: the parent's submit handler never sees it
-               expect(onKeydown).not.toHaveBeenCalled();
-               expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
-
-               const code = root.querySelector('code[data-code-token="block"]');
-               const selection = window.getSelection();
-
-               expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
-               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
-       });
-
-       it('adds a line after a still-open fence (no closing ``` yet)', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: '```js\nconst a = 1;'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-
-               // caret at the start of the block content (after the opening fence)
-               setSelection(root, (range) => {
-                       const target = textOffsetToRange(root, 6);
-
-                       range.setStart(target.startContainer, target.startOffset);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Enter}');
-               await tick();
-
-               expect(onKeydown).not.toHaveBeenCalled();
-               expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
-       });
-
-       it('forwards plain Enter to the parent when the caret is outside a block', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: BLOCK_SOURCE + '\nafter'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       range.selectNodeContents(root);
-                       range.collapse(false);
-               });
-
-               await userEvent.keyboard('{Enter}');
-
-               expect(onKeydown).toHaveBeenCalledTimes(1);
-               expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
-       });
-
-       it('forwards plain Enter on the trailing hatch line after a block', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: BLOCK_SOURCE
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               // root-level caret between the block and its trailing br hatch
-               setSelection(root, (range) => {
-                       range.setStart(root, 1);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Enter}');
-
-               expect(onKeydown).toHaveBeenCalledTimes(1);
-       });
-
-       it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: BLOCK_SOURCE
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               setSelection(root, (range) => {
-                       const target = textOffsetToRange(root, 6);
-
-                       range.setStart(target.startContainer, target.startOffset);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Control>}{Enter}{/Control}');
-
-               expect(onKeydown).toHaveBeenCalledWith(
-                       expect.objectContaining({ ctrlKey: true, key: 'Enter' })
-               );
-               expect(serializeContent(root)).toBe(BLOCK_SOURCE);
-       });
-
-       it('forwards Enter inside an inline code span', async () => {
-               const onKeydown = vi.fn();
-               const { container } = render(ChatFormContentEditable, {
-                       onKeydown,
-                       value: 'run `npm test` now'
-               });
-
-               await tick();
-
-               const root = editableIn(container);
-
-               root.focus();
-               const code = root.querySelector('code[data-code-token="inline"]')!;
-
-               setSelection(root, (range) => {
-                       range.setStart(code.firstChild!, 3);
-                       range.collapse(true);
-               });
-
-               await userEvent.keyboard('{Enter}');
-
-               expect(onKeydown).toHaveBeenCalledTimes(1);
-       });
-});
index a4fd375b3cd005eefd211444208f1b4e10fa3713..e7c1779276b85723828e012bf94bb0a582c7c2b9 100644 (file)
@@ -3,7 +3,7 @@
 // block region - closed, or still OPEN while the user is typing
 // one - plain Enter adds a line instead of submitting the message.
 // The textarea path is covered here end-to-end (the contenteditable
-// consumes the same case locally; see chat-form-contenteditable).
+// consumes the same case locally; see chat-form-input-rich).
 
 import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
 import { SETTINGS_KEYS } from '$lib/constants';
diff --git a/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts
new file mode 100644 (file)
index 0000000..ab7fa01
--- /dev/null
@@ -0,0 +1,179 @@
+// Guards the newline contract of the chat-form contenteditable: browsers
+// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
+// serialization must fold those back into `\n` so the emitted value never
+// diverges from what is on screen.
+
+import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+const SOURCE = 'see [docs](file:///a/b) here';
+
+function editableIn(container: HTMLElement): HTMLElement {
+       const el = container.querySelector('[role="textbox"]');
+
+       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
+       return el;
+}
+
+function fireInput(root: HTMLElement) {
+       root.dispatchEvent(new InputEvent('input', { bubbles: true }));
+}
+
+function setCaret(node: Node, offset: number) {
+       const range = document.createRange();
+
+       range.setStart(node, offset);
+       range.setEnd(node, offset);
+       const selection = window.getSelection();
+
+       if (!selection) throw new Error('no selection');
+
+       selection.removeAllRanges();
+       selection.addRange(range);
+}
+
+describe('ChatFormInputRich browser newline shapes', () => {
+       it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+               const div = document.createElement('div');
+
+               div.textContent = 'second line';
+               root.appendChild(div);
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
+       });
+
+       it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+               const first = document.createElement('div');
+
+               while (root.firstChild) first.appendChild(root.firstChild);
+               const second = document.createElement('div');
+
+               second.textContent = 'second line';
+               root.appendChild(first);
+               root.appendChild(second);
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
+       });
+
+       it('serializes a <br> as a newline', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'here' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               root.appendChild(document.createElement('br'));
+               root.appendChild(document.createTextNode('second line'));
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('here\nsecond line');
+       });
+
+       it('ignores a trailing <br> (browser caret placeholder)', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               root.appendChild(document.createElement('br'));
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('abc');
+       });
+
+       it('serializes one newline per empty-line <div><br></div>', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               for (let i = 0; i < 2; i++) {
+                       const div = document.createElement('div');
+
+                       div.appendChild(document.createElement('br'));
+                       root.appendChild(div);
+               }
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('abc\n\n');
+       });
+
+       it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+               const div = document.createElement('div');
+
+               div.appendChild(document.createElement('br'));
+               root.replaceChildren(div);
+               fireInput(root);
+               await tick();
+
+               expect(screen.component.getValue()).toBe('');
+               expect(root.dataset.empty).toBe('true');
+       });
+
+       it('maps the caret across block boundaries in both directions', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' });
+
+               await tick();
+
+               // Rebuild into the Chromium block shape; the source is unchanged,
+               // so no re-render fires.
+               const root = editableIn(screen.container);
+               const div = document.createElement('div');
+
+               div.textContent = 'def';
+               root.replaceChildren(document.createTextNode('abc'), div);
+               fireInput(root);
+               await tick();
+               expect(screen.component.getValue()).toBe('abc\ndef');
+
+               const divText = div.firstChild;
+
+               if (!divText) throw new Error('div text missing');
+
+               setCaret(divText, 2);
+               expect(screen.component.getCaretOffset()).toBe(6);
+
+               screen.component.setCaretOffset(6);
+               const selection = window.getSelection();
+
+               expect(selection?.anchorNode).toBe(divText);
+               expect(selection?.anchorOffset).toBe(2);
+
+               // The boundary newline itself: offset 3 is the end of "abc", offset
+               // 4 the start of the "def" line.
+               screen.component.setCaretOffset(4);
+               expect(window.getSelection()?.anchorNode).toBe(divText);
+               expect(window.getSelection()?.anchorOffset).toBe(0);
+
+               screen.component.setCaretOffset(3);
+               expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
+               expect(window.getSelection()?.anchorOffset).toBe(3);
+       });
+});
diff --git a/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts
new file mode 100644 (file)
index 0000000..a89a888
--- /dev/null
@@ -0,0 +1,161 @@
+// Guards the editing-key contract of the chat-form contenteditable:
+// undo/redo is replayed from source snapshots (the token rebuilds destroy
+// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
+// keyboard trap), matching the plain textarea.
+
+import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+const SOURCE = 'see [docs](file:///a/b)';
+
+function editableIn(container: HTMLElement): HTMLElement {
+       const el = container.querySelector('[role="textbox"]');
+
+       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
+       return el;
+}
+
+function type(root: HTMLElement, text: string, inputType = 'insertText') {
+       root.appendChild(document.createTextNode(text));
+       root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
+}
+
+function keydown(root: HTMLElement, init: KeyboardEventInit) {
+       const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
+
+       root.dispatchEvent(event);
+
+       return event;
+}
+
+describe('ChatFormInputRich undo/redo', () => {
+       it('undoes and redoes an edit across a badge-containing buffer', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               type(root, ' more');
+               await tick();
+               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
+
+               const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
+
+               await tick();
+               expect(undoEvent.defaultPrevented).toBe(true);
+               expect(screen.component.getValue()).toBe(SOURCE);
+
+               const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
+
+               await tick();
+               expect(redoEvent.defaultPrevented).toBe(true);
+               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
+       });
+
+       it('redoes with Ctrl+Y as well', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               type(root, ' more');
+               await tick();
+               keydown(root, { key: 'z', metaKey: true });
+               await tick();
+               expect(screen.component.getValue()).toBe(SOURCE);
+
+               keydown(root, { ctrlKey: true, key: 'y' });
+               await tick();
+               expect(screen.component.getValue()).toBe(`${SOURCE} more`);
+       });
+
+       it('coalesces a typing burst into one undo step', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               type(root, 'd');
+               type(root, 'e');
+               await tick();
+               expect(screen.component.getValue()).toBe('abcde');
+
+               keydown(root, { ctrlKey: true, key: 'z' });
+               await tick();
+               expect(screen.component.getValue()).toBe('abc');
+       });
+
+       it('keeps a newline as its own undo step', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               type(root, 'd');
+               type(root, '\n', 'insertLineBreak');
+               await tick();
+               expect(screen.component.getValue()).toBe('abcd\n');
+
+               keydown(root, { ctrlKey: true, key: 'z' });
+               await tick();
+               expect(screen.component.getValue()).toBe('abcd');
+
+               keydown(root, { ctrlKey: true, key: 'z' });
+               await tick();
+               expect(screen.component.getValue()).toBe('abc');
+       });
+
+       it('is a no-op when there is nothing to undo', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+               const event = keydown(root, { ctrlKey: true, key: 'z' });
+
+               await tick();
+
+               expect(event.defaultPrevented).toBe(true);
+               expect(screen.component.getValue()).toBe('abc');
+       });
+
+       it('abandons the redo branch after a fresh edit', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: 'abc' });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+
+               type(root, 'd');
+               await tick();
+               keydown(root, { ctrlKey: true, key: 'z' });
+               await tick();
+               expect(screen.component.getValue()).toBe('abc');
+
+               type(root, 'e');
+               await tick();
+               keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
+               await tick();
+               expect(screen.component.getValue()).toBe('abce');
+       });
+});
+
+describe('ChatFormInputRich Tab key', () => {
+       it('does not trap Tab (focus can leave the editable)', async () => {
+               const screen = render(ChatFormInputRichHarness, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(screen.container);
+               const event = keydown(root, { key: 'Tab' });
+
+               expect(event.defaultPrevented).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts
new file mode 100644 (file)
index 0000000..c5120fb
--- /dev/null
@@ -0,0 +1,804 @@
+// Guards the clipboard contract of the chat-form contenteditable:
+// copy/cut expose the markdown SOURCE of the selection (each badge
+// contributes its full `[name](file://...)` link) and pasting such
+// markdown re-renders the badges.
+
+import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
+import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
+import { tick } from 'svelte';
+import { describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const SOURCE = 'hello [docs](file:///a/b) world';
+const BADGE_SELECTOR = '[data-mention-badge="true"]';
+
+function editableIn(container: HTMLElement): HTMLElement {
+       const el = container.querySelector('[role="textbox"]');
+
+       if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
+
+       return el;
+}
+
+function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
+       const range = document.createRange();
+
+       place(range, root);
+       const selection = window.getSelection();
+
+       if (!selection) throw new Error('no selection');
+
+       selection.removeAllRanges();
+       selection.addRange(range);
+}
+
+function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
+       const data = new DataTransfer();
+
+       if (text) data.setData('text/plain', text);
+
+       const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
+
+       return { data, event };
+}
+
+describe('ChatFormInputRich clipboard', () => {
+       it('copy exposes the markdown source of the selection', async () => {
+               const { container } = render(ChatFormInputRich, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               setSelection(root, (range) => range.selectNodeContents(root));
+
+               const { data, event } = clipboardEvent('copy');
+
+               root.dispatchEvent(event);
+
+               expect(event.defaultPrevented).toBe(true);
+               expect(data.getData('text/plain')).toBe(SOURCE);
+       });
+
+       it('cut exposes the markdown source and removes the slice', async () => {
+               const { container } = render(ChatFormInputRich, { value: SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               setSelection(root, (range) => {
+                       const badge = root.querySelector(BADGE_SELECTOR);
+
+                       if (!badge) throw new Error('badge not rendered');
+
+                       range.setStartBefore(badge);
+                       range.setEndAfter(badge);
+               });
+
+               const { data, event } = clipboardEvent('cut');
+
+               root.dispatchEvent(event);
+
+               expect(event.defaultPrevented).toBe(true);
+               expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
+               expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
+               expect(root.textContent).toBe('hello  world');
+       });
+
+       it('paste of markdown mention links re-renders badges', async () => {
+               const { container } = render(ChatFormInputRich, { value: 'hello ' });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       range.selectNodeContents(root);
+                       range.collapse(false);
+               });
+
+               const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
+
+               root.dispatchEvent(event);
+               await tick();
+
+               expect(event.defaultPrevented).toBe(true);
+               const badge = root.querySelector(BADGE_SELECTOR);
+
+               expect(badge).not.toBeNull();
+               expect(badge!.getAttribute('data-mention-name')).toBe('docs');
+               expect(root.textContent).toContain('world');
+       });
+
+       it('paste without mention links keeps the DOM untouched', async () => {
+               const { container } = render(ChatFormInputRich, { value: 'hello ' });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       range.selectNodeContents(root);
+                       range.collapse(false);
+               });
+               const firstChild = root.firstChild;
+               const { event } = clipboardEvent('paste', 'plain text');
+
+               root.dispatchEvent(event);
+               await tick();
+
+               expect(event.defaultPrevented).toBe(true);
+               expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
+               // no rebuild: the live text node is the same instance
+               expect(root.firstChild).toBe(firstChild);
+       });
+});
+
+describe('ChatFormInputRich code spans', () => {
+       it('renders inline code from the initial value', async () => {
+               const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
+
+               await tick();
+
+               const root = editableIn(container);
+               const code = root.querySelector('code[data-code-token="code_inline"]');
+
+               expect(code).not.toBeNull();
+               expect(code!.textContent).toBe('`npm test`');
+       });
+
+       it('renders a fenced code block with a language', async () => {
+               const source = 'before\n```js\nconst a = 1;\n```\nafter';
+               const { container } = render(ChatFormInputRich, { value: source });
+
+               await tick();
+
+               const root = editableIn(container);
+               const code = root.querySelector('code[data-code-token="code_block"]');
+
+               expect(code).not.toBeNull();
+               expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
+       });
+
+       it('copy exposes the markdown source of a selection spanning code', async () => {
+               const source = 'run `npm test` now';
+               const { container } = render(ChatFormInputRich, { value: source });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               setSelection(root, (range) => range.selectNodeContents(root));
+
+               const { data, event } = clipboardEvent('copy');
+
+               root.dispatchEvent(event);
+
+               expect(event.defaultPrevented).toBe(true);
+               expect(data.getData('text/plain')).toBe(source);
+       });
+
+       it('paste of a code span renders the styled element', async () => {
+               const { container } = render(ChatFormInputRich, { value: 'run ' });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       range.selectNodeContents(root);
+                       range.collapse(false);
+               });
+
+               const { event } = clipboardEvent('paste', '`npm test` now');
+
+               root.dispatchEvent(event);
+               await tick();
+
+               expect(event.defaultPrevented).toBe(true);
+               const code = root.querySelector('code[data-code-token="code_inline"]');
+
+               expect(code).not.toBeNull();
+               expect(code!.textContent).toBe('`npm test`');
+               expect(root.textContent).toContain('now');
+       });
+
+       it('highlights a fenced block content and stays byte-exact', async () => {
+               const source = '```js\nconst a = 1;\n```';
+               const { container } = render(ChatFormInputRich, { value: source });
+
+               await tick();
+
+               const root = editableIn(container);
+               const code = root.querySelector('code[data-code-token="code_block"]');
+
+               expect(code).not.toBeNull();
+               expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
+               expect(code!.textContent).toBe(source);
+       });
+
+       it('does not highlight inline code', async () => {
+               const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               expect(root.querySelector('[class*="hljs-"]')).toBeNull();
+       });
+});
+
+describe('ChatFormInputRich code block escape hatches', () => {
+       const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
+       const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
+
+       function blockIn(root: HTMLElement): HTMLElement {
+               const el = root.querySelector(BLOCK_SELECTOR);
+
+               if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
+
+               return el;
+       }
+
+       // Caret at the very start/end of the block's text (across highlight spans)
+       function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
+               const code = blockIn(root);
+               const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
+
+               let target: Node | null = null;
+
+               for (let n = walker.nextNode(); n; n = walker.nextNode()) {
+                       target = where === 'start' ? (target ?? n) : n;
+               }
+
+               if (!target) throw new Error('no text inside code block');
+
+               setSelection(root, (range) => {
+                       range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
+                       range.collapse(true);
+               });
+       }
+
+       function caretContainer(): Node {
+               const selection = window.getSelection();
+
+               if (!selection || selection.rangeCount === 0) throw new Error('no selection');
+
+               return selection.getRangeAt(0).startContainer;
+       }
+
+       it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               // no permanent empty line above a leading block
+               expect(root.firstChild).toBe(blockIn(root));
+               expect(root.lastChild?.nodeName).toBe('BR');
+
+               setSelection(root, (range) => range.selectNodeContents(root));
+               const { data, event } = clipboardEvent('copy');
+
+               root.dispatchEvent(event);
+
+               expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
+       });
+
+       it('escapes a trailing code block with ArrowDown and types after it', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               await userEvent.keyboard('{ArrowDown}');
+               expect(blockIn(root).contains(caretContainer())).toBe(false);
+
+               await userEvent.keyboard('x');
+               await tick();
+
+               expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
+               // the DOM holds no separator newline (it would render as a
+               // phantom empty line); serialization synthesizes it so the
+               // markdown source keeps the text below the block
+               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
+               // the stale trailing hatch is removed once real text follows the block
+               expect(root.lastChild?.nodeName).not.toBe('BR');
+       });
+
+       it('escapes a leading code block with ArrowUp and types before it', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'start');
+
+               await userEvent.keyboard('{ArrowUp}');
+               expect(blockIn(root).contains(caretContainer())).toBe(false);
+               // the transient hatch line exists while the caret sits on it
+               expect(root.firstChild?.nodeName).toBe('BR');
+
+               await userEvent.keyboard('y');
+               await tick();
+
+               expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
+               expect(root.textContent).toBe('y' + BLOCK_SOURCE);
+               expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
+               // the typed text consumed the hatch
+               expect(root.firstChild?.nodeName).not.toBe('BR');
+       });
+
+       it('escapes a leading code block with ArrowLeft from its first character', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'start');
+
+               await userEvent.keyboard('{ArrowLeft}');
+               expect(blockIn(root).contains(caretContainer())).toBe(false);
+               expect(root.firstChild?.nodeName).toBe('BR');
+       });
+
+       it('removes the transient leading hatch when the caret moves back into the block', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'start');
+
+               await userEvent.keyboard('{ArrowUp}');
+               expect(root.firstChild?.nodeName).toBe('BR');
+
+               await userEvent.keyboard('{ArrowDown}');
+               await tick();
+
+               expect(blockIn(root).contains(caretContainer())).toBe(true);
+               expect(root.firstChild).toBe(blockIn(root));
+       });
+
+       it('extends the selection out of the block with Shift+ArrowDown', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
+
+               const selection = window.getSelection();
+
+               expect(selection).not.toBeNull();
+               expect(selection!.isCollapsed).toBe(false);
+               expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
+       });
+
+       it('line-separates text typed right after the closing fence', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               // no arrow keys: the caret sits at the block's end edge, where the
+               // post-rebuild restore lands it, and the typed text renders on the
+               // line below the block
+               await userEvent.keyboard('x');
+               await tick();
+
+               // the text stays on the caret's line in the DOM (no phantom empty
+               // line); the source gets the separator newline
+               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
+       });
+
+       it('does not double the newline when Shift+Enter already added one', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
+               await userEvent.keyboard('x');
+               await tick();
+
+               expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
+       });
+
+       it('moves a caret stuck before the inserted newline onto the new line', async () => {
+               const { container } = render(ChatFormInputRich, {
+                       value: BLOCK_SOURCE + '\ntext after the code block'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+
+               // post-break DOM some browsers produce: the inserted newline plus
+               // the artificial trailing one, with the caret stuck BEFORE the
+               // inserted one (visually at the end of the old line)
+               root.appendChild(document.createTextNode('\n'));
+               root.appendChild(document.createTextNode('\n'));
+               setSelection(root, (range) => {
+                       range.setStart(root.childNodes[2], 0);
+                       range.collapse(true);
+               });
+
+               root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
+               await tick();
+
+               const selection = window.getSelection();
+
+               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
+                       (BLOCK_SOURCE + '\ntext after the code block\n').length
+               );
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
+       });
+
+       it('appends the artificial trailing newline when the browser did not add one', async () => {
+               const { container } = render(ChatFormInputRich, {
+                       value: BLOCK_SOURCE + '\ntext after the code block'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+
+               // post-break DOM some browsers produce: a lone trailing \n (or a
+               // <br> the hatch sync strips). Collapsed by the renderer, so the
+               // caret looks stuck on the old line and the next typed character
+               // would consume the newline.
+               root.appendChild(document.createTextNode('\n'));
+               setSelection(root, (range) => {
+                       range.setStart(root.childNodes[2], 1);
+                       range.collapse(true);
+               });
+
+               root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
+               await tick();
+
+               const selection = window.getSelection();
+
+               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
+                       (BLOCK_SOURCE + '\ntext after the code block\n').length
+               );
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
+       });
+
+       it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
+               const { container } = render(ChatFormInputRich, {
+                       value: BLOCK_SOURCE + '\ntext after the code block'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       const text = root.childNodes[1];
+
+                       range.setStart(text, (text.textContent ?? '').length);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
+               await tick();
+
+               const selection = window.getSelection();
+
+               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
+                       (BLOCK_SOURCE + '\ntext after the code block\n').length
+               );
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
+
+               // the next typed character lands on the new line
+               await userEvent.keyboard('x');
+               await tick();
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
+       });
+
+       it('lets Backspace at the text start move into the block without a source fight', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               await userEvent.keyboard('{ArrowDown}');
+               await userEvent.keyboard('create');
+               await tick();
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
+
+               // Backspace at the start of the text line: the separator newline
+               // is structural (synthesized while text follows the block), so
+               // the caret just moves to the block's edge - nothing is re-added
+               await userEvent.keyboard('{Home}');
+               await userEvent.keyboard('{Backspace}');
+               await tick();
+
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
+               expect(caretContainer()).toBe(root);
+       });
+
+       it('lets forward Delete eat the text after a block normally', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               placeCaretInBlock(root, 'end');
+
+               await userEvent.keyboard('{ArrowDown}');
+               await userEvent.keyboard('create');
+               await tick();
+
+               await userEvent.keyboard('{Home}');
+               await userEvent.keyboard('{Delete}');
+               await tick();
+
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
+       });
+
+       it('renders text after a block without a phantom empty line', async () => {
+               const { container } = render(ChatFormInputRich, {
+                       value: BLOCK_SOURCE + '\nhello'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
+       });
+
+       it('keeps an intentional blank line after a block out of the separator', async () => {
+               const { container } = render(ChatFormInputRich, {
+                       value: BLOCK_SOURCE + '\n\nhello'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
+       });
+
+       it('re-highlights while typing inside a block and keeps the caret', async () => {
+               const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+
+               // caret at the start of the block content (after the opening fence)
+               setSelection(root, (range) => {
+                       const target = textOffsetToRange(root, 6);
+
+                       range.setStart(target.startContainer, target.startOffset);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('x');
+               await tick();
+
+               const code = blockIn(root);
+
+               expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
+               expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
+               expect(code.querySelector('.hljs-number')).not.toBeNull();
+
+               const selection = window.getSelection();
+
+               expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
+               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
+       });
+});
+
+describe('ChatFormInputRich Enter in code blocks', () => {
+       const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
+
+       it('adds a line instead of submitting on plain Enter inside a block', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: BLOCK_SOURCE
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+
+               // caret at the start of the block content (after the opening fence)
+               setSelection(root, (range) => {
+                       const target = textOffsetToRange(root, 6);
+
+                       range.setStart(target.startContainer, target.startOffset);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Enter}');
+               await tick();
+
+               // consumed locally: the parent's submit handler never sees it
+               expect(onKeydown).not.toHaveBeenCalled();
+               expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
+
+               const code = root.querySelector('code[data-code-token="code_block"]');
+               const selection = window.getSelection();
+
+               expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
+               expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
+       });
+
+       it('adds a line after a still-open fence (no closing ``` yet)', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: '```js\nconst a = 1;'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+
+               // caret at the start of the block content (after the opening fence)
+               setSelection(root, (range) => {
+                       const target = textOffsetToRange(root, 6);
+
+                       range.setStart(target.startContainer, target.startOffset);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Enter}');
+               await tick();
+
+               expect(onKeydown).not.toHaveBeenCalled();
+               expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
+       });
+
+       it('forwards plain Enter to the parent when the caret is outside a block', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: BLOCK_SOURCE + '\nafter'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       range.selectNodeContents(root);
+                       range.collapse(false);
+               });
+
+               await userEvent.keyboard('{Enter}');
+
+               expect(onKeydown).toHaveBeenCalledTimes(1);
+               expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
+       });
+
+       it('forwards plain Enter on the trailing hatch line after a block', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: BLOCK_SOURCE
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               // root-level caret between the block and its trailing br hatch
+               setSelection(root, (range) => {
+                       range.setStart(root, 1);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Enter}');
+
+               expect(onKeydown).toHaveBeenCalledTimes(1);
+       });
+
+       it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: BLOCK_SOURCE
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               setSelection(root, (range) => {
+                       const target = textOffsetToRange(root, 6);
+
+                       range.setStart(target.startContainer, target.startOffset);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Control>}{Enter}{/Control}');
+
+               expect(onKeydown).toHaveBeenCalledWith(
+                       expect.objectContaining({ ctrlKey: true, key: 'Enter' })
+               );
+               expect(serializeContent(root)).toBe(BLOCK_SOURCE);
+       });
+
+       it('forwards Enter inside an inline code span', async () => {
+               const onKeydown = vi.fn();
+               const { container } = render(ChatFormInputRich, {
+                       onKeydown,
+                       value: 'run `npm test` now'
+               });
+
+               await tick();
+
+               const root = editableIn(container);
+
+               root.focus();
+               const code = root.querySelector('code[data-code-token="code_inline"]')!;
+
+               setSelection(root, (range) => {
+                       range.setStart(code.firstChild!, 3);
+                       range.collapse(true);
+               });
+
+               await userEvent.keyboard('{Enter}');
+
+               expect(onKeydown).toHaveBeenCalledTimes(1);
+       });
+});
index aaee18c991c690a2295db5e4caca14ebcae86e87..a6ff29597a67b2ec26a166bb30b9bb81fb20007c 100644 (file)
@@ -3,7 +3,7 @@
 // it, the picker still opens but explains why instead of firing searches
 // that would only fail with "Search failed".
 
-import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
+import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
 import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
 import { BuiltInTool } from '$lib/enums';
 import { toolsStore } from '$lib/stores/tools.svelte';
diff --git a/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte
deleted file mode 100644 (file)
index 21dddf5..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<script lang="ts">
-       import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte';
-       import { untrack } from 'svelte';
-
-       interface Props {
-               value?: string;
-       }
-
-       let { value: initial = '' }: Props = $props();
-
-       let value = $state(untrack(() => initial));
-       let inputRef: ChatFormContentEditable | undefined = $state(undefined);
-
-       export function getValue() {
-               return value;
-       }
-
-       export function getCaretOffset() {
-               return inputRef?.getCaretOffset();
-       }
-
-       export function setCaretOffset(offset: number) {
-               inputRef?.setCaretOffset(offset);
-       }
-</script>
-
-<ChatFormContentEditable bind:this={inputRef} bind:value />
diff --git a/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte b/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte
new file mode 100644 (file)
index 0000000..58768a1
--- /dev/null
@@ -0,0 +1,27 @@
+<script lang="ts">
+       import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
+       import { untrack } from 'svelte';
+
+       interface Props {
+               value?: string;
+       }
+
+       let { value: initial = '' }: Props = $props();
+
+       let value = $state(untrack(() => initial));
+       let inputRef: ChatFormInputRich | undefined = $state(undefined);
+
+       export function getValue() {
+               return value;
+       }
+
+       export function getCaretOffset() {
+               return inputRef?.getCaretOffset();
+       }
+
+       export function setCaretOffset(offset: number) {
+               inputRef?.setCaretOffset(offset);
+       }
+</script>
+
+<ChatFormInputRich bind:this={inputRef} bind:value />
diff --git a/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts b/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts
new file mode 100644 (file)
index 0000000..86baad2
--- /dev/null
@@ -0,0 +1,215 @@
+import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+describe('tokenizeContent', () => {
+       it('tokenizes a plain text buffer with no badges', () => {
+               expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
+       });
+
+       it('tokenizes a single badge', () => {
+               expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
+                       { kind: 'badge', name: 'docs', path: '/a/b' }
+               ]);
+       });
+
+       it('tokenizes text around a single badge', () => {
+               expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
+                       { kind: 'text', text: 'hello ' },
+                       { kind: 'badge', name: 'docs', path: '/a/b' },
+                       { kind: 'text', text: ' world' }
+               ]);
+       });
+
+       it('tokenizes adjacent badges as separate tokens', () => {
+               expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
+                       { kind: 'badge', name: 'a', path: '/x' },
+                       { kind: 'badge', name: 'b', path: '/y' }
+               ]);
+       });
+
+       it('leaves non-file links untouched in the stream', () => {
+               expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
+                       { kind: 'text', text: 'see [foo](https://example.com) for details' }
+               ]);
+       });
+
+       it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
+               const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
+               const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
+                       { kind: 'text', text: ' ' }
+               ]);
+       });
+
+       it('recognizes badges whose path lives in the macOS temp folder', () => {
+               const path =
+                       '/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
+               const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
+                       { kind: 'text', text: ' ' }
+               ]);
+       });
+
+       it('keeps text around a badge with spaces in the path', () => {
+               const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
+               const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'text', text: 'see ' },
+                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
+                       { kind: 'text', text: ' done' }
+               ]);
+       });
+
+       it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
+               const path = '/Users/foo/Screenshot (1).png';
+               const source = `[Screenshot (1).png](file://${path}) `;
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'badge', name: 'Screenshot (1).png', path },
+                       { kind: 'text', text: ' ' }
+               ]);
+       });
+
+       it('recognizes badges whose folder name is wrapped in parentheses', () => {
+               const path = '/Users/foo/Project (Stuff)/main.rs';
+               const source = `[main.rs](file://${path}) `;
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'badge', name: 'main.rs', path },
+                       { kind: 'text', text: ' ' }
+               ]);
+       });
+
+       it('recognizes adjacent badges back-to-back with no separator', () => {
+               const source = '[a](file:///p)[b](file:///q)';
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'badge', name: 'a', path: '/p' },
+                       { kind: 'badge', name: 'b', path: '/q' }
+               ]);
+       });
+
+       it('tokenizes inline code with the backticks included', () => {
+               expect(tokenizeContent('run `npm test` now')).toEqual([
+                       { kind: 'text', text: 'run ' },
+                       { kind: 'code_inline', text: '`npm test`' },
+                       { kind: 'text', text: ' now' }
+               ]);
+       });
+
+       it('tokenizes a fenced code block without a language', () => {
+               const source = 'before\n```\nconst a = 1;\n```\nafter';
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'text', text: 'before\n' },
+                       { kind: 'code_block', text: '```\nconst a = 1;\n```' },
+                       { kind: 'text', text: '\nafter' }
+               ]);
+       });
+
+       it('tokenizes a fenced code block with a language', () => {
+               const source = '```js\nconst a = 1;\n```';
+
+               expect(tokenizeContent(source)).toEqual([
+                       { kind: 'code_block', text: '```js\nconst a = 1;\n```' }
+               ]);
+       });
+
+       it('prefers the fenced block over inline spans at triple backticks', () => {
+               expect(tokenizeContent('```a``` ```b```')).toEqual([
+                       { kind: 'code_block', text: '```a```' },
+                       { kind: 'text', text: ' ' },
+                       { kind: 'code_block', text: '```b```' }
+               ]);
+       });
+
+       it('leaves an unclosed fence as plain text', () => {
+               expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
+                       { kind: 'text', text: '```js\nconst a = 1;' }
+               ]);
+       });
+
+       it('leaves an unclosed inline backtick as plain text', () => {
+               expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
+       });
+
+       it('does not recognize badges inside code spans', () => {
+               expect(tokenizeContent('`[a](file:///p)`')).toEqual([
+                       { kind: 'code_inline', text: '`[a](file:///p)`' }
+               ]);
+       });
+
+       it('tokenizes badges and code spans side by side', () => {
+               expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
+                       { kind: 'badge', name: 'a', path: '/p' },
+                       { kind: 'text', text: ' ' },
+                       { kind: 'code_inline', text: '`x`' }
+               ]);
+       });
+});
+
+describe('containsCodeSpan', () => {
+       it('detects inline code', () => {
+               expect(containsCodeSpan('run `npm test` now')).toBe(true);
+       });
+
+       it('detects a fenced block with a language', () => {
+               expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
+       });
+
+       it('detects a fenced block without a language', () => {
+               expect(containsCodeSpan('```\ncode\n```')).toBe(true);
+       });
+
+       it('ignores unclosed fences and lone backticks', () => {
+               expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
+               expect(containsCodeSpan('run `npm test')).toBe(false);
+               expect(containsCodeSpan('``')).toBe(false);
+       });
+
+       it('ignores plain text and mention links', () => {
+               expect(containsCodeSpan('hello world')).toBe(false);
+               expect(containsCodeSpan('[a](file:///p)')).toBe(false);
+       });
+});
+
+describe('isOffsetInCodeBlock', () => {
+       const BLOCK = '```js\nconst a = 1;\n```';
+
+       it('is false with no fences in the buffer', () => {
+               expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
+               expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
+       });
+
+       it('is true right after the opening fence, before any content', () => {
+               expect(isOffsetInCodeBlock('```', 3)).toBe(true);
+               expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
+       });
+
+       it('is true inside a still-open block while it is being typed', () => {
+               const open = '```js\nconst a = 1;';
+
+               expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
+       });
+
+       it('is true inside a closed block and false outside it', () => {
+               expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
+               expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
+               expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
+               expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
+       });
+
+       it('toggles per fence across multiple blocks', () => {
+               const two = BLOCK + '\ntext\n' + BLOCK;
+               const secondBlock = two.lastIndexOf(BLOCK);
+
+               expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
+               expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
+               expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
+       });
+});
diff --git a/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts b/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts
new file mode 100644 (file)
index 0000000..03eb974
--- /dev/null
@@ -0,0 +1,83 @@
+import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+// Layout of `hello [docs](file:///a/b) world foo`:
+//   "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
+const BADGE = '[docs](file:///a/b)';
+const SOURCE = `hello ${BADGE} world foo`;
+const BADGE_START = 6;
+const BADGE_END = 25;
+
+describe('badgeAwareWordJump', () => {
+       it('returns null when the buffer has no badge', () => {
+               expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
+               expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
+       });
+
+       it('jumps forward onto a badge landing at its end, not the next word', () => {
+               expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
+       });
+
+       it('jumps forward from the space before a badge landing at its end', () => {
+               expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
+       });
+
+       it('jumps backward over a badge landing at its start', () => {
+               expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
+       });
+
+       it('jumps backward from the next word onto the badge start', () => {
+               // caret at the start of "world"
+               expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
+       });
+
+       it('returns null for jumps that cross no badge', () => {
+               // forward over "hello" only
+               expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
+               // backward over "foo" only
+               expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
+               // backward away from the badge (over "hello")
+               expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
+       });
+
+       it('treats a leading badge as one word in both directions', () => {
+               const source = `${BADGE} rest`;
+
+               expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
+               expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
+       });
+
+       it('treats adjacent badges as separate words', () => {
+               // each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
+               const source = '[a](file:///x)[b](file:///y)';
+
+               expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
+               expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
+               expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
+               expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
+       });
+
+       it('jumps over a badge following punctuation', () => {
+               // "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
+               const source = `foo, ${BADGE} bar`;
+
+               expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
+               expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
+       });
+});
+
+describe('leadingBadgeEdgeOffset', () => {
+       it('returns 0 when the caret sits exactly at a leading badge end', () => {
+               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
+       });
+
+       it('returns null when the caret is anywhere else', () => {
+               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
+               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
+       });
+
+       it('returns null when the buffer does not start with a badge', () => {
+               expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
+               expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
+       });
+});
diff --git a/tools/ui/tests/unit/contenteditable-tokenizer.test.ts b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts
deleted file mode 100644 (file)
index 27a22aa..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
-import { describe, expect, it } from 'vitest';
-
-describe('tokenizeContent', () => {
-       it('tokenizes a plain text buffer with no badges', () => {
-               expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
-       });
-
-       it('tokenizes a single badge', () => {
-               expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
-                       { kind: 'badge', name: 'docs', path: '/a/b' }
-               ]);
-       });
-
-       it('tokenizes text around a single badge', () => {
-               expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
-                       { kind: 'text', text: 'hello ' },
-                       { kind: 'badge', name: 'docs', path: '/a/b' },
-                       { kind: 'text', text: ' world' }
-               ]);
-       });
-
-       it('tokenizes adjacent badges as separate tokens', () => {
-               expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
-                       { kind: 'badge', name: 'a', path: '/x' },
-                       { kind: 'badge', name: 'b', path: '/y' }
-               ]);
-       });
-
-       it('leaves non-file links untouched in the stream', () => {
-               expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
-                       { kind: 'text', text: 'see [foo](https://example.com) for details' }
-               ]);
-       });
-
-       it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
-               const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
-               const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
-                       { kind: 'text', text: ' ' }
-               ]);
-       });
-
-       it('recognizes badges whose path lives in the macOS temp folder', () => {
-               const path =
-                       '/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
-               const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
-                       { kind: 'text', text: ' ' }
-               ]);
-       });
-
-       it('keeps text around a badge with spaces in the path', () => {
-               const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
-               const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'text', text: 'see ' },
-                       { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
-                       { kind: 'text', text: ' done' }
-               ]);
-       });
-
-       it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
-               const path = '/Users/foo/Screenshot (1).png';
-               const source = `[Screenshot (1).png](file://${path}) `;
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'badge', name: 'Screenshot (1).png', path },
-                       { kind: 'text', text: ' ' }
-               ]);
-       });
-
-       it('recognizes badges whose folder name is wrapped in parentheses', () => {
-               const path = '/Users/foo/Project (Stuff)/main.rs';
-               const source = `[main.rs](file://${path}) `;
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'badge', name: 'main.rs', path },
-                       { kind: 'text', text: ' ' }
-               ]);
-       });
-
-       it('recognizes adjacent badges back-to-back with no separator', () => {
-               const source = '[a](file:///p)[b](file:///q)';
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'badge', name: 'a', path: '/p' },
-                       { kind: 'badge', name: 'b', path: '/q' }
-               ]);
-       });
-
-       it('tokenizes inline code with the backticks included', () => {
-               expect(tokenizeContent('run `npm test` now')).toEqual([
-                       { kind: 'text', text: 'run ' },
-                       { kind: 'inlineCode', text: '`npm test`' },
-                       { kind: 'text', text: ' now' }
-               ]);
-       });
-
-       it('tokenizes a fenced code block without a language', () => {
-               const source = 'before\n```\nconst a = 1;\n```\nafter';
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'text', text: 'before\n' },
-                       { kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
-                       { kind: 'text', text: '\nafter' }
-               ]);
-       });
-
-       it('tokenizes a fenced code block with a language', () => {
-               const source = '```js\nconst a = 1;\n```';
-
-               expect(tokenizeContent(source)).toEqual([
-                       { kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
-               ]);
-       });
-
-       it('prefers the fenced block over inline spans at triple backticks', () => {
-               expect(tokenizeContent('```a``` ```b```')).toEqual([
-                       { kind: 'codeBlock', text: '```a```' },
-                       { kind: 'text', text: ' ' },
-                       { kind: 'codeBlock', text: '```b```' }
-               ]);
-       });
-
-       it('leaves an unclosed fence as plain text', () => {
-               expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
-                       { kind: 'text', text: '```js\nconst a = 1;' }
-               ]);
-       });
-
-       it('leaves an unclosed inline backtick as plain text', () => {
-               expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
-       });
-
-       it('does not recognize badges inside code spans', () => {
-               expect(tokenizeContent('`[a](file:///p)`')).toEqual([
-                       { kind: 'inlineCode', text: '`[a](file:///p)`' }
-               ]);
-       });
-
-       it('tokenizes badges and code spans side by side', () => {
-               expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
-                       { kind: 'badge', name: 'a', path: '/p' },
-                       { kind: 'text', text: ' ' },
-                       { kind: 'inlineCode', text: '`x`' }
-               ]);
-       });
-});
-
-describe('containsCodeSpan', () => {
-       it('detects inline code', () => {
-               expect(containsCodeSpan('run `npm test` now')).toBe(true);
-       });
-
-       it('detects a fenced block with a language', () => {
-               expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
-       });
-
-       it('detects a fenced block without a language', () => {
-               expect(containsCodeSpan('```\ncode\n```')).toBe(true);
-       });
-
-       it('ignores unclosed fences and lone backticks', () => {
-               expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
-               expect(containsCodeSpan('run `npm test')).toBe(false);
-               expect(containsCodeSpan('``')).toBe(false);
-       });
-
-       it('ignores plain text and mention links', () => {
-               expect(containsCodeSpan('hello world')).toBe(false);
-               expect(containsCodeSpan('[a](file:///p)')).toBe(false);
-       });
-});
-
-describe('isOffsetInCodeBlock', () => {
-       const BLOCK = '```js\nconst a = 1;\n```';
-
-       it('is false with no fences in the buffer', () => {
-               expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
-               expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
-       });
-
-       it('is true right after the opening fence, before any content', () => {
-               expect(isOffsetInCodeBlock('```', 3)).toBe(true);
-               expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
-       });
-
-       it('is true inside a still-open block while it is being typed', () => {
-               const open = '```js\nconst a = 1;';
-
-               expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
-       });
-
-       it('is true inside a closed block and false outside it', () => {
-               expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
-               expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
-               expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
-               expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
-       });
-
-       it('toggles per fence across multiple blocks', () => {
-               const two = BLOCK + '\ntext\n' + BLOCK;
-               const secondBlock = two.lastIndexOf(BLOCK);
-
-               expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
-               expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
-               expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
-       });
-});
diff --git a/tools/ui/tests/unit/contenteditable-word-jump.test.ts b/tools/ui/tests/unit/contenteditable-word-jump.test.ts
deleted file mode 100644 (file)
index 03eb974..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
-import { describe, expect, it } from 'vitest';
-
-// Layout of `hello [docs](file:///a/b) world foo`:
-//   "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
-const BADGE = '[docs](file:///a/b)';
-const SOURCE = `hello ${BADGE} world foo`;
-const BADGE_START = 6;
-const BADGE_END = 25;
-
-describe('badgeAwareWordJump', () => {
-       it('returns null when the buffer has no badge', () => {
-               expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
-               expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
-       });
-
-       it('jumps forward onto a badge landing at its end, not the next word', () => {
-               expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
-       });
-
-       it('jumps forward from the space before a badge landing at its end', () => {
-               expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
-       });
-
-       it('jumps backward over a badge landing at its start', () => {
-               expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
-       });
-
-       it('jumps backward from the next word onto the badge start', () => {
-               // caret at the start of "world"
-               expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
-       });
-
-       it('returns null for jumps that cross no badge', () => {
-               // forward over "hello" only
-               expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
-               // backward over "foo" only
-               expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
-               // backward away from the badge (over "hello")
-               expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
-       });
-
-       it('treats a leading badge as one word in both directions', () => {
-               const source = `${BADGE} rest`;
-
-               expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
-               expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
-       });
-
-       it('treats adjacent badges as separate words', () => {
-               // each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
-               const source = '[a](file:///x)[b](file:///y)';
-
-               expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
-               expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
-               expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
-               expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
-       });
-
-       it('jumps over a badge following punctuation', () => {
-               // "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
-               const source = `foo, ${BADGE} bar`;
-
-               expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
-               expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
-       });
-});
-
-describe('leadingBadgeEdgeOffset', () => {
-       it('returns 0 when the caret sits exactly at a leading badge end', () => {
-               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
-       });
-
-       it('returns null when the caret is anywhere else', () => {
-               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
-               expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
-       });
-
-       it('returns null when the buffer does not start with a badge', () => {
-               expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
-               expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
-       });
-});