+++ /dev/null
-<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>
--- /dev/null
+<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>
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}
+++ /dev/null
-<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>
--- /dev/null
+<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 />
--- /dev/null
+<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>
--- /dev/null
+<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>
+++ /dev/null
-<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}"
-/>
--- /dev/null
+<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}
--- /dev/null
+<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>
--- /dev/null
+<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}"
+/>
--- /dev/null
+<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>
+++ /dev/null
-<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>
+++ /dev/null
-<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
<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,
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 {
}
</script>
-<ChatFormCommandPicker
+<ChatFormPickerCommand
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{onPromptLoadError}
/>
-<ChatFormMentionPicker
+<ChatFormPickerMention
bind:this={mentionPickerRef}
isOpen={isMentionPickerOpen ?? false}
query={mentionQuery ?? ''}
+++ /dev/null
-<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>
+++ /dev/null
-<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 />
+++ /dev/null
-<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>
+++ /dev/null
-<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>
* 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';
* 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.)
/**
* 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.
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
* 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
* 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';
* 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)
}
/**
- * 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'
}
ReasoningFormat,
ChatFormCommandAction,
FileMentionEntryType,
- ContentEditableTokenKind
+ ChatFormInputRichTokenKind
} from './chat.enums';
export { SessionRecordType } from './conversation-import.enums';
--- /dev/null
+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 };
+++ /dev/null
-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 };
} from './glob';
// Contenteditable token types (chat form)
-export type { ContentEditableToken } from './contenteditable';
+export type { ChatFormInputRichToken } from './chat-form-input-rich';
// Agentic types
export type {
--- /dev/null
+/**
+ * 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;
+}
+++ /dev/null
-/**
- * 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;
-}
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';
+++ /dev/null
-// 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);
- });
-});
+++ /dev/null
-// 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);
- });
-});
+++ /dev/null
-// 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);
- });
-});
// 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';
--- /dev/null
+// 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);
+ });
+});
--- /dev/null
+// 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);
+ });
+});
--- /dev/null
+// 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);
+ });
+});
// 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';
+++ /dev/null
-<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 />
--- /dev/null
+<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 />
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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();
+ });
+});
+++ /dev/null
-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);
- });
-});
+++ /dev/null
-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();
- });
-});