From: Aleksander Grygier Date: Thu, 13 Aug 2026 17:45:32 +0000 (+0200) Subject: refactor: Naming (#27001) X-Git-Tag: upstream/0.0.10438~17 X-Git-Url: https://git.djapps.eu/?a=commitdiff_plain;h=fa4ec4590cc73ccd23df5841e9c7d17bd22b0bb7;p=pkg%2Fggml%2Fsources%2Fllama.cpp refactor: Naming (#27001) --- diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte deleted file mode 100644 index 0efe052b1..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview.svelte +++ /dev/null @@ -1,213 +0,0 @@ - - -
-
- 1} /> - -
- {#if currentItem} - - - - {/if} - - -
-
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte new file mode 100644 index 000000000..0efe052b1 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte @@ -0,0 +1,213 @@ + + +
+
+ 1} /> + +
+ {#if currentItem} + + + + {/if} + + +
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index bf56e9108..49c3dfe20 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -3,12 +3,11 @@ import { ChatAttachmentsList, ChatFormActions, - ChatFormContentEditable, - ChatFormFileInputInvisible, + ChatFormCurrentWorkingDirectory, + ChatFormInput, + ChatFormInputFileInputInvisible, ChatFormMcpResourcesList, ChatFormPickers, - ChatFormTextarea, - ChatFormWorkingDirectory, DialogMcpResourcesBrowser } from '$lib/components/app'; import { @@ -121,7 +120,7 @@ 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); @@ -544,7 +543,7 @@ } - +
- {#if useContenteditable} - { - pickers.handleInput(); - onValueChange?.(value); - }} - onPaste={handlePaste} - {disabled} - {placeholder} - /> - {:else} - { - pickers.handleInput(); - onValueChange?.(value); - }} - onPaste={handlePaste} - {disabled} - {placeholder} - /> - {/if} + { + pickers.handleInput(); + onValueChange?.(value); + }} + onPaste={handlePaste} + {disabled} + {placeholder} + {useContenteditable} + /> {#if mcpResourceStore.hasAttachments} {#if toolsStore.hasEnabledCwdTools} - - 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 (`
`, - // `

`, 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(); - - 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('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
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 `
` 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 `
` 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 `
` 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(); - } - } - - -
-
-
- - diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte new file mode 100644 index 000000000..78e5b5cb9 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte @@ -0,0 +1,413 @@ + + + + + + + + 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" + > +
+ + + {#if !fileSearchEnabled} +
{searchUnavailableMessage}
+ {:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)} + nav.setHover(index)} + /> + {/if} + + {#if pickerSupported && fileSearchEnabled} + + {/if} + + {#if homeBase && fileSearchEnabled} + + + + Searching in: + + {abbreviateHome(searchScope, homeBase)} + + {/if} +
+
+
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte new file mode 100644 index 000000000..23661d223 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryChip.svelte @@ -0,0 +1,70 @@ + + + +
+ + + {#if showTooltip && displayLabelTitle} + + + {#snippet child({ props })} + {displayLabel} + {/snippet} + + +

{displayLabelTitle}

+
+
+ {:else} + {displayLabel} + {/if} +
+ + {#if directory} +
+ +
+ {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte new file mode 100644 index 000000000..32de06189 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectoryResultsList.svelte @@ -0,0 +1,72 @@ + + +
+ {#if isSearching && results.length === 0} +
Searching...
+ {:else if error} +
{error}
+ {:else if results.length === 0} +
No matching folders
+ {:else} + {#each results as path, index (path)} + + {/each} + {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte deleted file mode 100644 index 395ecb201..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormFileInputInvisible.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte new file mode 100644 index 000000000..2b92b30fe --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInput.svelte @@ -0,0 +1,80 @@ + + +{#if useContenteditable} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte new file mode 100644 index 000000000..623744d8a --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputBasic.svelte @@ -0,0 +1,82 @@ + + +
+ +
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte new file mode 100644 index 000000000..395ecb201 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte @@ -0,0 +1,31 @@ + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte new file mode 100644 index 000000000..f25c3c95c --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte @@ -0,0 +1,849 @@ + + +
+
+
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte deleted file mode 100644 index df654b25b..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - - - command.name} - scrollTrigger={nav.scrollTrigger} - > - {#snippet item(command, index, isSelected)} - {@const Icon = commandIcon[command.action]} - handleSelect(command)} - onmouseenter={() => { - if (!command.disabled) nav.setHover(index); - }} - > - -
- /{command.name} - - {command.description} - -
-
- {/snippet} -
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte deleted file mode 100644 index 1c7c8f7d4..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte +++ /dev/null @@ -1,278 +0,0 @@ - - - { - if (!open) onClose(); - }} -> - - - - 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 - ]} - > - entry.type + ':' + entry.path} - scrollTrigger={nav.scrollTrigger} - > - {#snippet item(entry, index, isSelected)} - handleSelect(entry)} - onmouseenter={() => nav.setHover(index)} - > - {@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File} - -
-
- {#if showTooltip} - - - {#snippet child({ props })} - {entry.name} - {/snippet} - - -

{entry.path}

-
-
- {:else} - {entry.name} - {/if} - - {entry.type} - -
- - - -
-
- {/snippet} -
-
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte new file mode 100644 index 000000000..df654b25b --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerCommand.svelte @@ -0,0 +1,142 @@ + + + + command.name} + scrollTrigger={nav.scrollTrigger} + > + {#snippet item(command, index, isSelected)} + {@const Icon = commandIcon[command.action]} + handleSelect(command)} + onmouseenter={() => { + if (!command.disabled) nav.setHover(index); + }} + > + +
+ /{command.name} + + {command.description} + +
+
+ {/snippet} +
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte new file mode 100644 index 000000000..1c7c8f7d4 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte @@ -0,0 +1,278 @@ + + + { + if (!open) onClose(); + }} +> + + + + 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 + ]} + > + entry.type + ':' + entry.path} + scrollTrigger={nav.scrollTrigger} + > + {#snippet item(entry, index, isSelected)} + handleSelect(entry)} + onmouseenter={() => nav.setHover(index)} + > + {@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File} + +
+
+ {#if showTooltip} + + + {#snippet child({ props })} + {entry.name} + {/snippet} + + +

{entry.path}

+
+
+ {:else} + {entry.name} + {/if} + + {entry.type} + +
+ + + +
+
+ {/snippet} +
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte index 7ba97cf9b..dbe03e2e0 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -1,7 +1,7 @@ - - - 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); - } - - -
- -
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte deleted file mode 100644 index b1652d026..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte +++ /dev/null @@ -1,413 +0,0 @@ - - - - - - - - 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" - > -
- - - {#if !fileSearchEnabled} -
{searchUnavailableMessage}
- {:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)} - nav.setHover(index)} - /> - {/if} - - {#if pickerSupported && fileSearchEnabled} - - {/if} - - {#if homeBase && fileSearchEnabled} - - - - Searching in: - - {abbreviateHome(searchScope, homeBase)} - - {/if} -
-
-
- - diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte deleted file mode 100644 index 23661d223..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - - -
- - - {#if showTooltip && displayLabelTitle} - - - {#snippet child({ props })} - {displayLabel} - {/snippet} - - -

{displayLabelTitle}

-
-
- {:else} - {displayLabel} - {/if} -
- - {#if directory} -
- -
- {/if} -
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte deleted file mode 100644 index 32de06189..000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- {#if isSearching && results.length === 0} -
Searching...
- {:else if error} -
{error}
- {:else if results.length === 0} -
No matching folders
- {:else} - {#each results as path, index (path)} - - {/each} - {/if} -
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 5c8848264..d9e408c96 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -91,7 +91,7 @@ export { default as ChatAttachmentsListItemThumbnailImage } from './ChatAttachme * preview without carousel, or a gallery/carousel view when multiple items exist. * Uses ChatAttachmentPreviewSingle internally for each item's content. */ -export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview.svelte'; +export { default as ChatAttachmentsPreview } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreview.svelte'; export { default as ChatAttachmentsPreviewNavButtons } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewNavButtons.svelte'; export { default as ChatAttachmentsPreviewFileInfo } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewFileInfo.svelte'; export { default as ChatAttachmentsPreviewThumbnailStrip } from './ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte'; @@ -120,8 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. * * **Architecture:** - * - Composes ChatFormTextarea (or ChatFormContentEditable for messages with - * file mention links), ChatFormActions, and ChatFormPickerMcpPrompts + * - Composes ChatFormInput (a plain textarea, or a contenteditable for + * messages with file mention links), ChatFormActions, and ChatFormPickerMcpPrompts * - Manages file upload state via `uploadedFiles` bindable prop * - Integrates with ModelsSelectorDropdown for model selection in router mode * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.) @@ -258,7 +258,7 @@ export { default as ChatFormContextGauge } from './ChatForm/ChatFormContextGauge /** * Hidden file input element for programmatic file selection. */ -export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileInputInvisible.svelte'; +export { default as ChatFormInputFileInputInvisible } from './ChatForm/ChatFormInput/ChatFormInputFileInputInvisible.svelte'; /** * Displays MCP Resource attachments as a horizontal carousel. @@ -267,18 +267,13 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; /** - * Auto-resizing contenteditable input that renders `[name](file://...)` - * mention links as inline chips while keeping the value as the markdown - * source string. ChatForm swaps it in once a mention link lands in the - * buffer. Shares the focus()/resetHeight()/caret handle with the textarea. + * The message editor. Renders a plain auto-resizing textarea by default, + * or a contenteditable that renders `[name](file://...)` mention links as + * inline chips (keeping the value as the markdown source string) once a + * mention link lands in the buffer. The variant is selected via the + * `useContenteditable` prop; both share one imperative handle. */ -export { default as ChatFormContentEditable } from './ChatForm/ChatFormContentEditable.svelte'; - -/** - * Plain auto-resizing textarea with IME composition support. Default input - * renderer inside ChatForm until a file mention lands. - */ -export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'; +export { default as ChatFormInput } from './ChatForm/ChatFormInput/ChatFormInput.svelte'; /** * Working directory selector for agent mode. Renders a chip below the chat @@ -288,7 +283,7 @@ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte' * synthetic "Set working directory to ..." user message into chat history * and is enforced on tool calls via the `x-tool-cwd` request header. */ -export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte'; +export { default as ChatFormCurrentWorkingDirectory } from './ChatForm/ChatFormCurrentWorkingDirectory/ChatFormCurrentWorkingDirectory.svelte'; /** * **ChatFormPickerMcpPrompts** - MCP prompt selection interface @@ -359,14 +354,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha * Generic scrollable list for picker popovers. Provides search input, * scroll-into-view for keyboard navigation, loading skeletons, empty state, * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering. - * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention. */ export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte'; /** * Generic button wrapper for picker list items. Provides consistent styling, * hover/selected states, and data-picker-index attribute for scroll-into-view. - * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker. + * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMention. */ export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte'; @@ -389,14 +384,14 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi * tool, scoped to the conversation cwd (or server home when unset). * Selection splices a `[name](file:///)` 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) diff --git a/tools/ui/src/lib/enums/chat.enums.ts b/tools/ui/src/lib/enums/chat.enums.ts index ac3969536..152b42c12 100644 --- a/tools/ui/src/lib/enums/chat.enums.ts +++ b/tools/ui/src/lib/enums/chat.enums.ts @@ -91,11 +91,11 @@ export enum FileMentionEntryType { } /** - * Kinds of tokens the chat-form contenteditable produces. + * Kinds of tokens the chat-form-input-rich produces. */ -export enum ContentEditableTokenKind { +export enum ChatFormInputRichTokenKind { TEXT = 'text', BADGE = 'badge', - INLINE_CODE = 'inlineCode', - CODE_BLOCK = 'codeBlock' + CODE_INLINE = 'code_inline', + CODE_BLOCK = 'code_block' } diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts index 226cc134d..5d0884b98 100644 --- a/tools/ui/src/lib/enums/index.ts +++ b/tools/ui/src/lib/enums/index.ts @@ -28,7 +28,7 @@ export { ReasoningFormat, ChatFormCommandAction, FileMentionEntryType, - ContentEditableTokenKind + ChatFormInputRichTokenKind } from './chat.enums'; export { SessionRecordType } from './conversation-import.enums'; diff --git a/tools/ui/src/lib/types/chat-form-input-rich.d.ts b/tools/ui/src/lib/types/chat-form-input-rich.d.ts new file mode 100644 index 000000000..307bc3381 --- /dev/null +++ b/tools/ui/src/lib/types/chat-form-input-rich.d.ts @@ -0,0 +1,11 @@ +import { ChatFormInputRichTokenKind } from '$lib/enums'; + +/** + * A single token produced by the chat-form-input-rich tokenizer: + * plain text, a file/folder mention badge, or an inline/fenced code span. + */ +export type ChatFormInputRichToken = + | { kind: ChatFormInputRichTokenKind.TEXT; text: string } + | { kind: ChatFormInputRichTokenKind.BADGE; name: string; path: string } + | { kind: ChatFormInputRichTokenKind.CODE_INLINE; text: string } + | { kind: ChatFormInputRichTokenKind.CODE_BLOCK; text: string }; diff --git a/tools/ui/src/lib/types/contenteditable.d.ts b/tools/ui/src/lib/types/contenteditable.d.ts deleted file mode 100644 index 8c610bfed..000000000 --- a/tools/ui/src/lib/types/contenteditable.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ContentEditableTokenKind } from '$lib/enums'; - -/** - * A single token produced by the chat-form contenteditable tokenizer: - * plain text, a file/folder mention badge, or an inline/fenced code span. - */ -export type ContentEditableToken = - | { kind: ContentEditableTokenKind.TEXT; text: string } - | { kind: ContentEditableTokenKind.BADGE; name: string; path: string } - | { kind: ContentEditableTokenKind.INLINE_CODE; text: string } - | { kind: ContentEditableTokenKind.CODE_BLOCK; text: string }; diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 34767e2ae..497bfd415 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -183,7 +183,7 @@ export type { } from './glob'; // Contenteditable token types (chat form) -export type { ContentEditableToken } from './contenteditable'; +export type { ChatFormInputRichToken } from './chat-form-input-rich'; // Agentic types export type { diff --git a/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts b/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts new file mode 100644 index 000000000..7af350b24 --- /dev/null +++ b/tools/ui/src/lib/utils/chat-form-input-rich-tokenizer.ts @@ -0,0 +1,1059 @@ +/** + * Maps between the chat-form-input-rich's markdown source and the + * badge/code/text token stream the DOM is built from. A badge is one + * opaque source contribution (`[name](file://path)`); its own subtree + * is never walked, and the caret cannot land inside it, so offsets + * resolve to the nearest badge edge. Code spans (``) + * 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 (`
` line wrappers, `
` + * 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. `
` and block + * wrappers the browser inserted for newlines fold back into `\n` (a + * trailing `
` 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 `
` 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 `
` 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, `` elements for badges, + * `` 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 `
` 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 + * `
` 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 `
` 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/`
` newline shapes as + * `serializeContent`. + */ +export function textOffsetToRange(root: HTMLElement, offset: number): Range { + const range = document.createRange(); + + let remaining = offset; + let landed = false; + let pendingBlockBoundary = false; + + const land = (node: Node, nodeOffset: number) => { + range.setStart(node, nodeOffset); + range.setEnd(node, nodeOffset); + landed = true; + }; + const walk = (parent: Node) => { + let first = true; + + for (const child of Array.from(parent.childNodes)) { + if (landed) return; + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + + if (text.length === 0) continue; + + if (pendingBlockBoundary) { + // The synthesized separator maps to the near edge of the + // content that follows the block. + pendingBlockBoundary = false; + + if (remaining === 0) { + land(child, 0); + + return; + } + + remaining -= 1; + } + + if (remaining <= text.length) { + land(child, remaining); + + return; + } + + remaining -= text.length; + first = false; + + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + const el = child as HTMLElement; + + if (el.dataset.mentionBadge === 'true') { + const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? ''); + + if (len === 0) continue; + + if (pendingBlockBoundary) { + pendingBlockBoundary = false; + + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + remaining -= 1; + } + + if (remaining <= len) { + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + } else { + range.setStartAfter(el); + range.setEndAfter(el); + } + + landed = true; + + return; + } + + remaining -= len; + first = false; + + continue; + } + + if (el.dataset.codeToken !== undefined) { + const isBlock = el.dataset.codeToken === ChatFormInputRichTokenKind.CODE_BLOCK; + + if (isBlock && (pendingBlockBoundary || !first)) { + pendingBlockBoundary = false; + + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + remaining -= 1; + } + + const len = (el.textContent ?? '').length; + + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + if (remaining === len) { + range.setStartAfter(el); + range.setEndAfter(el); + landed = true; + + return; + } + + if (remaining < len) { + walk(el); + + return; + } + + remaining -= len; + + if (isBlock) remaining -= 1; + + first = false; + + continue; + } + + if (el.tagName === 'BR') { + const isHatch = + isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling); + + if (isHatch) { + // Escape hatch: no source length; offset 0 lands before it + // so text typed there takes its place. + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + } + + continue; + } + + if (!el.nextSibling) continue; + + if (pendingBlockBoundary) { + pendingBlockBoundary = false; + + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + remaining -= 1; + } + + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + remaining -= 1; + first = false; + + continue; + } + + if (BLOCK_TAG_NAMES.has(el.tagName)) { + if (pendingBlockBoundary || !first) { + pendingBlockBoundary = false; + + if (remaining === 0) { + // The boundary newline belongs to the previous line. + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + + return; + } + + remaining -= 1; + } + + walk(el); + first = false; + + continue; + } + + const before = remaining; + + walk(el); + + if (remaining < before) first = false; + } + }; + + walk(root); + + if (!landed) { + const last = root.lastChild; + + if (last && last.nodeName === 'BR') { + range.setStartBefore(last); + range.setEndBefore(last); + } else { + range.selectNodeContents(root); + range.collapse(false); + } + } + + return range; +} diff --git a/tools/ui/src/lib/utils/contenteditable-tokenizer.ts b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts deleted file mode 100644 index db271398c..000000000 --- a/tools/ui/src/lib/utils/contenteditable-tokenizer.ts +++ /dev/null @@ -1,1055 +0,0 @@ -/** - * Maps between the chat-form contenteditable's markdown source and the - * badge/code/text token stream the DOM is built from. A badge is one - * opaque source contribution (`[name](file://path)`); its own subtree - * is never walked, and the caret cannot land inside it, so offsets - * resolve to the nearest badge edge. Code spans (``) - * 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 (`
` line wrappers, `
` - * 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. `
` and block - * wrappers the browser inserted for newlines fold back into `\n` (a - * trailing `
` 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 `
` 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 `
` 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, `` elements for badges, - * `` 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 `
` 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 - * `
` 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 `
` 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/`
` 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; -} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 178dae7a4..abe214272 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -221,7 +221,7 @@ export { textOffsetToRange, badgeAwareWordJump, leadingBadgeEdgeOffset -} from './contenteditable-tokenizer'; +} from './chat-form-input-rich-tokenizer'; // Source-space undo/redo history for the chat-form contenteditable export { SourceHistory, type SourceHistoryEntry } from './source-history'; diff --git a/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts deleted file mode 100644 index 0b719c43f..000000000 --- a/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -// Guards the newline contract of the chat-form contenteditable: browsers -// restructure the flat DOM on Enter (`
` wrappers, `
` 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
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
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
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
(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

', 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

-only buffer as empty for the placeholder', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc' }); - - await tick(); - - const root = editableIn(screen.container); - const div = document.createElement('div'); - - div.appendChild(document.createElement('br')); - root.replaceChildren(div); - fireInput(root); - await tick(); - - expect(screen.component.getValue()).toBe(''); - expect(root.dataset.empty).toBe('true'); - }); - - it('maps the caret across block boundaries in both directions', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc\ndef' }); - - await tick(); - - // Rebuild into the Chromium block shape; the source is unchanged, - // so no re-render fires. - const root = editableIn(screen.container); - const div = document.createElement('div'); - - div.textContent = 'def'; - root.replaceChildren(document.createTextNode('abc'), div); - fireInput(root); - await tick(); - expect(screen.component.getValue()).toBe('abc\ndef'); - - const divText = div.firstChild; - - if (!divText) throw new Error('div text missing'); - - setCaret(divText, 2); - expect(screen.component.getCaretOffset()).toBe(6); - - screen.component.setCaretOffset(6); - const selection = window.getSelection(); - - expect(selection?.anchorNode).toBe(divText); - expect(selection?.anchorOffset).toBe(2); - - // The boundary newline itself: offset 3 is the end of "abc", offset - // 4 the start of the "def" line. - screen.component.setCaretOffset(4); - expect(window.getSelection()?.anchorNode).toBe(divText); - expect(window.getSelection()?.anchorOffset).toBe(0); - - screen.component.setCaretOffset(3); - expect(window.getSelection()?.anchorNode).toBe(root.firstChild); - expect(window.getSelection()?.anchorOffset).toBe(3); - }); -}); diff --git a/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts deleted file mode 100644 index 7ed01fd99..000000000 --- a/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Guards the editing-key contract of the chat-form contenteditable: -// undo/redo is replayed from source snapshots (the token rebuilds destroy -// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no -// keyboard trap), matching the plain textarea. - -import ChatFormContentEditableHarness from './components/ChatFormContentEditableHarness.svelte'; -import { tick } from 'svelte'; -import { describe, expect, it } from 'vitest'; -import { render } from 'vitest-browser-svelte'; - -const SOURCE = 'see [docs](file:///a/b)'; - -function editableIn(container: HTMLElement): HTMLElement { - const el = container.querySelector('[role="textbox"]'); - - if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); - - return el; -} - -function type(root: HTMLElement, text: string, inputType = 'insertText') { - root.appendChild(document.createTextNode(text)); - root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType })); -} - -function keydown(root: HTMLElement, init: KeyboardEventInit) { - const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); - - root.dispatchEvent(event); - - return event; -} - -describe('ChatFormContentEditable undo/redo', () => { - it('undoes and redoes an edit across a badge-containing buffer', async () => { - const screen = render(ChatFormContentEditableHarness, { value: SOURCE }); - - await tick(); - - const root = editableIn(screen.container); - - type(root, ' more'); - await tick(); - expect(screen.component.getValue()).toBe(`${SOURCE} more`); - - const undoEvent = keydown(root, { ctrlKey: true, key: 'z' }); - - await tick(); - expect(undoEvent.defaultPrevented).toBe(true); - expect(screen.component.getValue()).toBe(SOURCE); - - const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true }); - - await tick(); - expect(redoEvent.defaultPrevented).toBe(true); - expect(screen.component.getValue()).toBe(`${SOURCE} more`); - }); - - it('redoes with Ctrl+Y as well', async () => { - const screen = render(ChatFormContentEditableHarness, { value: SOURCE }); - - await tick(); - - const root = editableIn(screen.container); - - type(root, ' more'); - await tick(); - keydown(root, { key: 'z', metaKey: true }); - await tick(); - expect(screen.component.getValue()).toBe(SOURCE); - - keydown(root, { ctrlKey: true, key: 'y' }); - await tick(); - expect(screen.component.getValue()).toBe(`${SOURCE} more`); - }); - - it('coalesces a typing burst into one undo step', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc' }); - - await tick(); - - const root = editableIn(screen.container); - - type(root, 'd'); - type(root, 'e'); - await tick(); - expect(screen.component.getValue()).toBe('abcde'); - - keydown(root, { ctrlKey: true, key: 'z' }); - await tick(); - expect(screen.component.getValue()).toBe('abc'); - }); - - it('keeps a newline as its own undo step', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc' }); - - await tick(); - - const root = editableIn(screen.container); - - type(root, 'd'); - type(root, '\n', 'insertLineBreak'); - await tick(); - expect(screen.component.getValue()).toBe('abcd\n'); - - keydown(root, { ctrlKey: true, key: 'z' }); - await tick(); - expect(screen.component.getValue()).toBe('abcd'); - - keydown(root, { ctrlKey: true, key: 'z' }); - await tick(); - expect(screen.component.getValue()).toBe('abc'); - }); - - it('is a no-op when there is nothing to undo', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc' }); - - await tick(); - - const root = editableIn(screen.container); - const event = keydown(root, { ctrlKey: true, key: 'z' }); - - await tick(); - - expect(event.defaultPrevented).toBe(true); - expect(screen.component.getValue()).toBe('abc'); - }); - - it('abandons the redo branch after a fresh edit', async () => { - const screen = render(ChatFormContentEditableHarness, { value: 'abc' }); - - await tick(); - - const root = editableIn(screen.container); - - type(root, 'd'); - await tick(); - keydown(root, { ctrlKey: true, key: 'z' }); - await tick(); - expect(screen.component.getValue()).toBe('abc'); - - type(root, 'e'); - await tick(); - keydown(root, { ctrlKey: true, key: 'z', shiftKey: true }); - await tick(); - expect(screen.component.getValue()).toBe('abce'); - }); -}); - -describe('ChatFormContentEditable Tab key', () => { - it('does not trap Tab (focus can leave the editable)', async () => { - const screen = render(ChatFormContentEditableHarness, { value: SOURCE }); - - await tick(); - - const root = editableIn(screen.container); - const event = keydown(root, { key: 'Tab' }); - - expect(event.defaultPrevented).toBe(false); - }); -}); diff --git a/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts deleted file mode 100644 index 0767fa715..000000000 --- a/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts +++ /dev/null @@ -1,804 +0,0 @@ -// Guards the clipboard contract of the chat-form contenteditable: -// copy/cut expose the markdown SOURCE of the selection (each badge -// contributes its full `[name](file://...)` link) and pasting such -// markdown re-renders the badges. - -import ChatFormContentEditable from '$lib/components/app/chat/ChatForm/ChatFormContentEditable.svelte'; -import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils'; -import { tick } from 'svelte'; -import { describe, expect, it, vi } from 'vitest'; -import { userEvent } from 'vitest/browser'; -import { render } from 'vitest-browser-svelte'; - -const SOURCE = 'hello [docs](file:///a/b) world'; -const BADGE_SELECTOR = '[data-mention-badge="true"]'; - -function editableIn(container: HTMLElement): HTMLElement { - const el = container.querySelector('[role="textbox"]'); - - if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); - - return el; -} - -function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) { - const range = document.createRange(); - - place(range, root); - const selection = window.getSelection(); - - if (!selection) throw new Error('no selection'); - - selection.removeAllRanges(); - selection.addRange(range); -} - -function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') { - const data = new DataTransfer(); - - if (text) data.setData('text/plain', text); - - const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data }); - - return { data, event }; -} - -describe('ChatFormContentEditable clipboard', () => { - it('copy exposes the markdown source of the selection', async () => { - const { container } = render(ChatFormContentEditable, { value: SOURCE }); - - await tick(); - - const root = editableIn(container); - - setSelection(root, (range) => range.selectNodeContents(root)); - - const { data, event } = clipboardEvent('copy'); - - root.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(data.getData('text/plain')).toBe(SOURCE); - }); - - it('cut exposes the markdown source and removes the slice', async () => { - const { container } = render(ChatFormContentEditable, { value: SOURCE }); - - await tick(); - - const root = editableIn(container); - - setSelection(root, (range) => { - const badge = root.querySelector(BADGE_SELECTOR); - - if (!badge) throw new Error('badge not rendered'); - - range.setStartBefore(badge); - range.setEndAfter(badge); - }); - - const { data, event } = clipboardEvent('cut'); - - root.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(data.getData('text/plain')).toBe('[docs](file:///a/b)'); - expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); - expect(root.textContent).toBe('hello world'); - }); - - it('paste of markdown mention links re-renders badges', async () => { - const { container } = render(ChatFormContentEditable, { value: 'hello ' }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - setSelection(root, (range) => { - range.selectNodeContents(root); - range.collapse(false); - }); - - const { event } = clipboardEvent('paste', '[docs](file:///a/b) world'); - - root.dispatchEvent(event); - await tick(); - - expect(event.defaultPrevented).toBe(true); - const badge = root.querySelector(BADGE_SELECTOR); - - expect(badge).not.toBeNull(); - expect(badge!.getAttribute('data-mention-name')).toBe('docs'); - expect(root.textContent).toContain('world'); - }); - - it('paste without mention links keeps the DOM untouched', async () => { - const { container } = render(ChatFormContentEditable, { value: 'hello ' }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - setSelection(root, (range) => { - range.selectNodeContents(root); - range.collapse(false); - }); - const firstChild = root.firstChild; - const { event } = clipboardEvent('paste', 'plain text'); - - root.dispatchEvent(event); - await tick(); - - expect(event.defaultPrevented).toBe(true); - expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); - // no rebuild: the live text node is the same instance - expect(root.firstChild).toBe(firstChild); - }); -}); - -describe('ChatFormContentEditable code spans', () => { - it('renders inline code from the initial value', async () => { - const { container } = render(ChatFormContentEditable, { value: 'run `npm test` now' }); - - await tick(); - - const root = editableIn(container); - const code = root.querySelector('code[data-code-token="inline"]'); - - expect(code).not.toBeNull(); - expect(code!.textContent).toBe('`npm test`'); - }); - - it('renders a fenced code block with a language', async () => { - const source = 'before\n```js\nconst a = 1;\n```\nafter'; - const { container } = render(ChatFormContentEditable, { value: source }); - - await tick(); - - const root = editableIn(container); - const code = root.querySelector('code[data-code-token="block"]'); - - expect(code).not.toBeNull(); - expect(code!.textContent).toBe('```js\nconst a = 1;\n```'); - }); - - it('copy exposes the markdown source of a selection spanning code', async () => { - const source = 'run `npm test` now'; - const { container } = render(ChatFormContentEditable, { value: source }); - - await tick(); - - const root = editableIn(container); - - setSelection(root, (range) => range.selectNodeContents(root)); - - const { data, event } = clipboardEvent('copy'); - - root.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(data.getData('text/plain')).toBe(source); - }); - - it('paste of a code span renders the styled element', async () => { - const { container } = render(ChatFormContentEditable, { value: 'run ' }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - setSelection(root, (range) => { - range.selectNodeContents(root); - range.collapse(false); - }); - - const { event } = clipboardEvent('paste', '`npm test` now'); - - root.dispatchEvent(event); - await tick(); - - expect(event.defaultPrevented).toBe(true); - const code = root.querySelector('code[data-code-token="inline"]'); - - expect(code).not.toBeNull(); - expect(code!.textContent).toBe('`npm test`'); - expect(root.textContent).toContain('now'); - }); - - it('highlights a fenced block content and stays byte-exact', async () => { - const source = '```js\nconst a = 1;\n```'; - const { container } = render(ChatFormContentEditable, { value: source }); - - await tick(); - - const root = editableIn(container); - const code = root.querySelector('code[data-code-token="block"]'); - - expect(code).not.toBeNull(); - expect(code!.querySelector('.hljs-keyword')).not.toBeNull(); - expect(code!.textContent).toBe(source); - }); - - it('does not highlight inline code', async () => { - const { container } = render(ChatFormContentEditable, { value: 'run `const` now' }); - - await tick(); - - const root = editableIn(container); - - expect(root.querySelector('[class*="hljs-"]')).toBeNull(); - }); -}); - -describe('ChatFormContentEditable code block escape hatches', () => { - const BLOCK_SOURCE = '```js\nconst a = 1;\n```'; - const BLOCK_SELECTOR = 'code[data-code-token="block"]'; - - function blockIn(root: HTMLElement): HTMLElement { - const el = root.querySelector(BLOCK_SELECTOR); - - if (!(el instanceof HTMLElement)) throw new Error('code block not rendered'); - - return el; - } - - // Caret at the very start/end of the block's text (across highlight spans) - function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') { - const code = blockIn(root); - const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); - - let target: Node | null = null; - - for (let n = walker.nextNode(); n; n = walker.nextNode()) { - target = where === 'start' ? (target ?? n) : n; - } - - if (!target) throw new Error('no text inside code block'); - - setSelection(root, (range) => { - range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length); - range.collapse(true); - }); - } - - function caretContainer(): Node { - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) throw new Error('no selection'); - - return selection.getRangeAt(0).startContainer; - } - - it('pads a trailing code block with a br hatch that stays invisible to copy', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - // no permanent empty line above a leading block - expect(root.firstChild).toBe(blockIn(root)); - expect(root.lastChild?.nodeName).toBe('BR'); - - setSelection(root, (range) => range.selectNodeContents(root)); - const { data, event } = clipboardEvent('copy'); - - root.dispatchEvent(event); - - expect(data.getData('text/plain')).toBe(BLOCK_SOURCE); - }); - - it('escapes a trailing code block with ArrowDown and types after it', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'end'); - - await userEvent.keyboard('{ArrowDown}'); - expect(blockIn(root).contains(caretContainer())).toBe(false); - - await userEvent.keyboard('x'); - await tick(); - - expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); - // the DOM holds no separator newline (it would render as a - // phantom empty line); serialization synthesizes it so the - // markdown source keeps the text below the block - expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); - expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); - // the stale trailing hatch is removed once real text follows the block - expect(root.lastChild?.nodeName).not.toBe('BR'); - }); - - it('escapes a leading code block with ArrowUp and types before it', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'start'); - - await userEvent.keyboard('{ArrowUp}'); - expect(blockIn(root).contains(caretContainer())).toBe(false); - // the transient hatch line exists while the caret sits on it - expect(root.firstChild?.nodeName).toBe('BR'); - - await userEvent.keyboard('y'); - await tick(); - - expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); - expect(root.textContent).toBe('y' + BLOCK_SOURCE); - expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE); - // the typed text consumed the hatch - expect(root.firstChild?.nodeName).not.toBe('BR'); - }); - - it('escapes a leading code block with ArrowLeft from its first character', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'start'); - - await userEvent.keyboard('{ArrowLeft}'); - expect(blockIn(root).contains(caretContainer())).toBe(false); - expect(root.firstChild?.nodeName).toBe('BR'); - }); - - it('removes the transient leading hatch when the caret moves back into the block', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'start'); - - await userEvent.keyboard('{ArrowUp}'); - expect(root.firstChild?.nodeName).toBe('BR'); - - await userEvent.keyboard('{ArrowDown}'); - await tick(); - - expect(blockIn(root).contains(caretContainer())).toBe(true); - expect(root.firstChild).toBe(blockIn(root)); - }); - - it('extends the selection out of the block with Shift+ArrowDown', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'end'); - - await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}'); - - const selection = window.getSelection(); - - expect(selection).not.toBeNull(); - expect(selection!.isCollapsed).toBe(false); - expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false); - }); - - it('line-separates text typed right after the closing fence', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'end'); - - // no arrow keys: the caret sits at the block's end edge, where the - // post-rebuild restore lands it, and the typed text renders on the - // line below the block - await userEvent.keyboard('x'); - await tick(); - - // the text stays on the caret's line in the DOM (no phantom empty - // line); the source gets the separator newline - expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); - expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); - }); - - it('does not double the newline when Shift+Enter already added one', async () => { - const { container } = render(ChatFormContentEditable, { value: BLOCK_SOURCE }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - placeCaretInBlock(root, 'end'); - - await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - await userEvent.keyboard('x'); - await tick(); - - expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); - expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); - }); - - it('moves a caret stuck before the inserted newline onto the new line', async () => { - const { container } = render(ChatFormContentEditable, { - value: BLOCK_SOURCE + '\ntext after the code block' - }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - - // post-break DOM some browsers produce: the inserted newline plus - // the artificial trailing one, with the caret stuck BEFORE the - // inserted one (visually at the end of the old line) - root.appendChild(document.createTextNode('\n')); - root.appendChild(document.createTextNode('\n')); - setSelection(root, (range) => { - range.setStart(root.childNodes[2], 0); - range.collapse(true); - }); - - root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' })); - await tick(); - - const selection = window.getSelection(); - - expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe( - (BLOCK_SOURCE + '\ntext after the code block\n').length - ); - expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n'); - }); - - it('appends the artificial trailing newline when the browser did not add one', async () => { - const { container } = render(ChatFormContentEditable, { - value: BLOCK_SOURCE + '\ntext after the code block' - }); - - await tick(); - - const root = editableIn(container); - - root.focus(); - - // post-break DOM some browsers produce: a lone trailing \n (or a - //
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); - }); -}); diff --git a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts index a4fd375b3..e7c177927 100644 --- a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts +++ b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts @@ -3,7 +3,7 @@ // block region - closed, or still OPEN while the user is typing // one - plain Enter adds a line instead of submitting the message. // The textarea path is covered here end-to-end (the contenteditable -// consumes the same case locally; see chat-form-contenteditable). +// consumes the same case locally; see chat-form-input-rich). import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte'; import { SETTINGS_KEYS } from '$lib/constants'; diff --git a/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts new file mode 100644 index 000000000..ab7fa0133 --- /dev/null +++ b/tools/ui/tests/client/chat-form-input-rich-blocks.svelte.test.ts @@ -0,0 +1,179 @@ +// Guards the newline contract of the chat-form contenteditable: browsers +// restructure the flat DOM on Enter (`
` wrappers, `
` 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
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
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
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
(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

', 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

-only buffer as empty for the placeholder', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc' }); + + await tick(); + + const root = editableIn(screen.container); + const div = document.createElement('div'); + + div.appendChild(document.createElement('br')); + root.replaceChildren(div); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe(''); + expect(root.dataset.empty).toBe('true'); + }); + + it('maps the caret across block boundaries in both directions', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' }); + + await tick(); + + // Rebuild into the Chromium block shape; the source is unchanged, + // so no re-render fires. + const root = editableIn(screen.container); + const div = document.createElement('div'); + + div.textContent = 'def'; + root.replaceChildren(document.createTextNode('abc'), div); + fireInput(root); + await tick(); + expect(screen.component.getValue()).toBe('abc\ndef'); + + const divText = div.firstChild; + + if (!divText) throw new Error('div text missing'); + + setCaret(divText, 2); + expect(screen.component.getCaretOffset()).toBe(6); + + screen.component.setCaretOffset(6); + const selection = window.getSelection(); + + expect(selection?.anchorNode).toBe(divText); + expect(selection?.anchorOffset).toBe(2); + + // The boundary newline itself: offset 3 is the end of "abc", offset + // 4 the start of the "def" line. + screen.component.setCaretOffset(4); + expect(window.getSelection()?.anchorNode).toBe(divText); + expect(window.getSelection()?.anchorOffset).toBe(0); + + screen.component.setCaretOffset(3); + expect(window.getSelection()?.anchorNode).toBe(root.firstChild); + expect(window.getSelection()?.anchorOffset).toBe(3); + }); +}); diff --git a/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts new file mode 100644 index 000000000..a89a888e0 --- /dev/null +++ b/tools/ui/tests/client/chat-form-input-rich-undo.svelte.test.ts @@ -0,0 +1,161 @@ +// Guards the editing-key contract of the chat-form contenteditable: +// undo/redo is replayed from source snapshots (the token rebuilds destroy +// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no +// keyboard trap), matching the plain textarea. + +import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte'; +import { tick } from 'svelte'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const SOURCE = 'see [docs](file:///a/b)'; + +function editableIn(container: HTMLElement): HTMLElement { + const el = container.querySelector('[role="textbox"]'); + + if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); + + return el; +} + +function type(root: HTMLElement, text: string, inputType = 'insertText') { + root.appendChild(document.createTextNode(text)); + root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType })); +} + +function keydown(root: HTMLElement, init: KeyboardEventInit) { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + + root.dispatchEvent(event); + + return event; +} + +describe('ChatFormInputRich undo/redo', () => { + it('undoes and redoes an edit across a badge-containing buffer', async () => { + const screen = render(ChatFormInputRichHarness, { value: SOURCE }); + + await tick(); + + const root = editableIn(screen.container); + + type(root, ' more'); + await tick(); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + + const undoEvent = keydown(root, { ctrlKey: true, key: 'z' }); + + await tick(); + expect(undoEvent.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe(SOURCE); + + const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true }); + + await tick(); + expect(redoEvent.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + }); + + it('redoes with Ctrl+Y as well', async () => { + const screen = render(ChatFormInputRichHarness, { value: SOURCE }); + + await tick(); + + const root = editableIn(screen.container); + + type(root, ' more'); + await tick(); + keydown(root, { key: 'z', metaKey: true }); + await tick(); + expect(screen.component.getValue()).toBe(SOURCE); + + keydown(root, { ctrlKey: true, key: 'y' }); + await tick(); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + }); + + it('coalesces a typing burst into one undo step', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc' }); + + await tick(); + + const root = editableIn(screen.container); + + type(root, 'd'); + type(root, 'e'); + await tick(); + expect(screen.component.getValue()).toBe('abcde'); + + keydown(root, { ctrlKey: true, key: 'z' }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('keeps a newline as its own undo step', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc' }); + + await tick(); + + const root = editableIn(screen.container); + + type(root, 'd'); + type(root, '\n', 'insertLineBreak'); + await tick(); + expect(screen.component.getValue()).toBe('abcd\n'); + + keydown(root, { ctrlKey: true, key: 'z' }); + await tick(); + expect(screen.component.getValue()).toBe('abcd'); + + keydown(root, { ctrlKey: true, key: 'z' }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('is a no-op when there is nothing to undo', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc' }); + + await tick(); + + const root = editableIn(screen.container); + const event = keydown(root, { ctrlKey: true, key: 'z' }); + + await tick(); + + expect(event.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('abandons the redo branch after a fresh edit', async () => { + const screen = render(ChatFormInputRichHarness, { value: 'abc' }); + + await tick(); + + const root = editableIn(screen.container); + + type(root, 'd'); + await tick(); + keydown(root, { ctrlKey: true, key: 'z' }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + + type(root, 'e'); + await tick(); + keydown(root, { ctrlKey: true, key: 'z', shiftKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abce'); + }); +}); + +describe('ChatFormInputRich Tab key', () => { + it('does not trap Tab (focus can leave the editable)', async () => { + const screen = render(ChatFormInputRichHarness, { value: SOURCE }); + + await tick(); + + const root = editableIn(screen.container); + const event = keydown(root, { key: 'Tab' }); + + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts b/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts new file mode 100644 index 000000000..c5120fb19 --- /dev/null +++ b/tools/ui/tests/client/chat-form-input-rich.svelte.test.ts @@ -0,0 +1,804 @@ +// Guards the clipboard contract of the chat-form contenteditable: +// copy/cut expose the markdown SOURCE of the selection (each badge +// contributes its full `[name](file://...)` link) and pasting such +// markdown re-renders the badges. + +import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte'; +import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils'; +import { tick } from 'svelte'; +import { describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-svelte'; + +const SOURCE = 'hello [docs](file:///a/b) world'; +const BADGE_SELECTOR = '[data-mention-badge="true"]'; + +function editableIn(container: HTMLElement): HTMLElement { + const el = container.querySelector('[role="textbox"]'); + + if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); + + return el; +} + +function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) { + const range = document.createRange(); + + place(range, root); + const selection = window.getSelection(); + + if (!selection) throw new Error('no selection'); + + selection.removeAllRanges(); + selection.addRange(range); +} + +function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') { + const data = new DataTransfer(); + + if (text) data.setData('text/plain', text); + + const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data }); + + return { data, event }; +} + +describe('ChatFormInputRich clipboard', () => { + it('copy exposes the markdown source of the selection', async () => { + const { container } = render(ChatFormInputRich, { value: SOURCE }); + + await tick(); + + const root = editableIn(container); + + setSelection(root, (range) => range.selectNodeContents(root)); + + const { data, event } = clipboardEvent('copy'); + + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe(SOURCE); + }); + + it('cut exposes the markdown source and removes the slice', async () => { + const { container } = render(ChatFormInputRich, { value: SOURCE }); + + await tick(); + + const root = editableIn(container); + + setSelection(root, (range) => { + const badge = root.querySelector(BADGE_SELECTOR); + + if (!badge) throw new Error('badge not rendered'); + + range.setStartBefore(badge); + range.setEndAfter(badge); + }); + + const { data, event } = clipboardEvent('cut'); + + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe('[docs](file:///a/b)'); + expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); + expect(root.textContent).toBe('hello world'); + }); + + it('paste of markdown mention links re-renders badges', async () => { + const { container } = render(ChatFormInputRich, { value: 'hello ' }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + + const { event } = clipboardEvent('paste', '[docs](file:///a/b) world'); + + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + const badge = root.querySelector(BADGE_SELECTOR); + + expect(badge).not.toBeNull(); + expect(badge!.getAttribute('data-mention-name')).toBe('docs'); + expect(root.textContent).toContain('world'); + }); + + it('paste without mention links keeps the DOM untouched', async () => { + const { container } = render(ChatFormInputRich, { value: 'hello ' }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + const firstChild = root.firstChild; + const { event } = clipboardEvent('paste', 'plain text'); + + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); + // no rebuild: the live text node is the same instance + expect(root.firstChild).toBe(firstChild); + }); +}); + +describe('ChatFormInputRich code spans', () => { + it('renders inline code from the initial value', async () => { + const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' }); + + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="code_inline"]'); + + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('`npm test`'); + }); + + it('renders a fenced code block with a language', async () => { + const source = 'before\n```js\nconst a = 1;\n```\nafter'; + const { container } = render(ChatFormInputRich, { value: source }); + + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="code_block"]'); + + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('```js\nconst a = 1;\n```'); + }); + + it('copy exposes the markdown source of a selection spanning code', async () => { + const source = 'run `npm test` now'; + const { container } = render(ChatFormInputRich, { value: source }); + + await tick(); + + const root = editableIn(container); + + setSelection(root, (range) => range.selectNodeContents(root)); + + const { data, event } = clipboardEvent('copy'); + + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe(source); + }); + + it('paste of a code span renders the styled element', async () => { + const { container } = render(ChatFormInputRich, { value: 'run ' }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + + const { event } = clipboardEvent('paste', '`npm test` now'); + + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + const code = root.querySelector('code[data-code-token="code_inline"]'); + + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('`npm test`'); + expect(root.textContent).toContain('now'); + }); + + it('highlights a fenced block content and stays byte-exact', async () => { + const source = '```js\nconst a = 1;\n```'; + const { container } = render(ChatFormInputRich, { value: source }); + + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="code_block"]'); + + expect(code).not.toBeNull(); + expect(code!.querySelector('.hljs-keyword')).not.toBeNull(); + expect(code!.textContent).toBe(source); + }); + + it('does not highlight inline code', async () => { + const { container } = render(ChatFormInputRich, { value: 'run `const` now' }); + + await tick(); + + const root = editableIn(container); + + expect(root.querySelector('[class*="hljs-"]')).toBeNull(); + }); +}); + +describe('ChatFormInputRich code block escape hatches', () => { + const BLOCK_SOURCE = '```js\nconst a = 1;\n```'; + const BLOCK_SELECTOR = 'code[data-code-token="code_block"]'; + + function blockIn(root: HTMLElement): HTMLElement { + const el = root.querySelector(BLOCK_SELECTOR); + + if (!(el instanceof HTMLElement)) throw new Error('code block not rendered'); + + return el; + } + + // Caret at the very start/end of the block's text (across highlight spans) + function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') { + const code = blockIn(root); + const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); + + let target: Node | null = null; + + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + target = where === 'start' ? (target ?? n) : n; + } + + if (!target) throw new Error('no text inside code block'); + + setSelection(root, (range) => { + range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length); + range.collapse(true); + }); + } + + function caretContainer(): Node { + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0) throw new Error('no selection'); + + return selection.getRangeAt(0).startContainer; + } + + it('pads a trailing code block with a br hatch that stays invisible to copy', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + // no permanent empty line above a leading block + expect(root.firstChild).toBe(blockIn(root)); + expect(root.lastChild?.nodeName).toBe('BR'); + + setSelection(root, (range) => range.selectNodeContents(root)); + const { data, event } = clipboardEvent('copy'); + + root.dispatchEvent(event); + + expect(data.getData('text/plain')).toBe(BLOCK_SOURCE); + }); + + it('escapes a trailing code block with ArrowDown and types after it', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{ArrowDown}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + + await userEvent.keyboard('x'); + await tick(); + + expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); + // the DOM holds no separator newline (it would render as a + // phantom empty line); serialization synthesizes it so the + // markdown source keeps the text below the block + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + // the stale trailing hatch is removed once real text follows the block + expect(root.lastChild?.nodeName).not.toBe('BR'); + }); + + it('escapes a leading code block with ArrowUp and types before it', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowUp}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + // the transient hatch line exists while the caret sits on it + expect(root.firstChild?.nodeName).toBe('BR'); + + await userEvent.keyboard('y'); + await tick(); + + expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); + expect(root.textContent).toBe('y' + BLOCK_SOURCE); + expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE); + // the typed text consumed the hatch + expect(root.firstChild?.nodeName).not.toBe('BR'); + }); + + it('escapes a leading code block with ArrowLeft from its first character', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowLeft}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + expect(root.firstChild?.nodeName).toBe('BR'); + }); + + it('removes the transient leading hatch when the caret moves back into the block', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowUp}'); + expect(root.firstChild?.nodeName).toBe('BR'); + + await userEvent.keyboard('{ArrowDown}'); + await tick(); + + expect(blockIn(root).contains(caretContainer())).toBe(true); + expect(root.firstChild).toBe(blockIn(root)); + }); + + it('extends the selection out of the block with Shift+ArrowDown', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}'); + + const selection = window.getSelection(); + + expect(selection).not.toBeNull(); + expect(selection!.isCollapsed).toBe(false); + expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false); + }); + + it('line-separates text typed right after the closing fence', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'end'); + + // no arrow keys: the caret sits at the block's end edge, where the + // post-rebuild restore lands it, and the typed text renders on the + // line below the block + await userEvent.keyboard('x'); + await tick(); + + // the text stays on the caret's line in the DOM (no phantom empty + // line); the source gets the separator newline + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + }); + + it('does not double the newline when Shift+Enter already added one', async () => { + const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + await userEvent.keyboard('x'); + await tick(); + + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + }); + + it('moves a caret stuck before the inserted newline onto the new line', async () => { + const { container } = render(ChatFormInputRich, { + value: BLOCK_SOURCE + '\ntext after the code block' + }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + + // post-break DOM some browsers produce: the inserted newline plus + // the artificial trailing one, with the caret stuck BEFORE the + // inserted one (visually at the end of the old line) + root.appendChild(document.createTextNode('\n')); + root.appendChild(document.createTextNode('\n')); + setSelection(root, (range) => { + range.setStart(root.childNodes[2], 0); + range.collapse(true); + }); + + root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' })); + await tick(); + + const selection = window.getSelection(); + + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe( + (BLOCK_SOURCE + '\ntext after the code block\n').length + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n'); + }); + + it('appends the artificial trailing newline when the browser did not add one', async () => { + const { container } = render(ChatFormInputRich, { + value: BLOCK_SOURCE + '\ntext after the code block' + }); + + await tick(); + + const root = editableIn(container); + + root.focus(); + + // post-break DOM some browsers produce: a lone trailing \n (or a + //
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); + }); +}); diff --git a/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts b/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts index aaee18c99..a6ff29597 100644 --- a/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts +++ b/tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts @@ -3,7 +3,7 @@ // it, the picker still opens but explains why instead of firing searches // that would only fail with "Search failed". -import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte'; +import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte'; import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants'; import { BuiltInTool } from '$lib/enums'; import { toolsStore } from '$lib/stores/tools.svelte'; diff --git a/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte deleted file mode 100644 index 21dddf5ba..000000000 --- a/tools/ui/tests/client/components/ChatFormContentEditableHarness.svelte +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte b/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte new file mode 100644 index 000000000..58768a1e8 --- /dev/null +++ b/tools/ui/tests/client/components/ChatFormInputRichHarness.svelte @@ -0,0 +1,27 @@ + + + diff --git a/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts b/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts new file mode 100644 index 000000000..86baad207 --- /dev/null +++ b/tools/ui/tests/unit/chat-form-input-rich-tokenizer.test.ts @@ -0,0 +1,215 @@ +import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils'; +import { describe, expect, it } from 'vitest'; + +describe('tokenizeContent', () => { + it('tokenizes a plain text buffer with no badges', () => { + expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]); + }); + + it('tokenizes a single badge', () => { + expect(tokenizeContent('[docs](file:///a/b)')).toEqual([ + { kind: 'badge', name: 'docs', path: '/a/b' } + ]); + }); + + it('tokenizes text around a single badge', () => { + expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([ + { kind: 'text', text: 'hello ' }, + { kind: 'badge', name: 'docs', path: '/a/b' }, + { kind: 'text', text: ' world' } + ]); + }); + + it('tokenizes adjacent badges as separate tokens', () => { + expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([ + { kind: 'badge', name: 'a', path: '/x' }, + { kind: 'badge', name: 'b', path: '/y' } + ]); + }); + + it('leaves non-file links untouched in the stream', () => { + expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([ + { kind: 'text', text: 'see [foo](https://example.com) for details' } + ]); + }); + + it('recognizes badges whose path contains spaces (macOS screenshots)', () => { + const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; + const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes badges whose path lives in the macOS temp folder', () => { + const path = + '/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png'; + const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('keeps text around a badge with spaces in the path', () => { + const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; + const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, + { kind: 'text', text: ' done' } + ]); + }); + + it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => { + const path = '/Users/foo/Screenshot (1).png'; + const source = `[Screenshot (1).png](file://${path}) `; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot (1).png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes badges whose folder name is wrapped in parentheses', () => { + const path = '/Users/foo/Project (Stuff)/main.rs'; + const source = `[main.rs](file://${path}) `; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'main.rs', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes adjacent badges back-to-back with no separator', () => { + const source = '[a](file:///p)[b](file:///q)'; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'a', path: '/p' }, + { kind: 'badge', name: 'b', path: '/q' } + ]); + }); + + it('tokenizes inline code with the backticks included', () => { + expect(tokenizeContent('run `npm test` now')).toEqual([ + { kind: 'text', text: 'run ' }, + { kind: 'code_inline', text: '`npm test`' }, + { kind: 'text', text: ' now' } + ]); + }); + + it('tokenizes a fenced code block without a language', () => { + const source = 'before\n```\nconst a = 1;\n```\nafter'; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'text', text: 'before\n' }, + { kind: 'code_block', text: '```\nconst a = 1;\n```' }, + { kind: 'text', text: '\nafter' } + ]); + }); + + it('tokenizes a fenced code block with a language', () => { + const source = '```js\nconst a = 1;\n```'; + + expect(tokenizeContent(source)).toEqual([ + { kind: 'code_block', text: '```js\nconst a = 1;\n```' } + ]); + }); + + it('prefers the fenced block over inline spans at triple backticks', () => { + expect(tokenizeContent('```a``` ```b```')).toEqual([ + { kind: 'code_block', text: '```a```' }, + { kind: 'text', text: ' ' }, + { kind: 'code_block', text: '```b```' } + ]); + }); + + it('leaves an unclosed fence as plain text', () => { + expect(tokenizeContent('```js\nconst a = 1;')).toEqual([ + { kind: 'text', text: '```js\nconst a = 1;' } + ]); + }); + + it('leaves an unclosed inline backtick as plain text', () => { + expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]); + }); + + it('does not recognize badges inside code spans', () => { + expect(tokenizeContent('`[a](file:///p)`')).toEqual([ + { kind: 'code_inline', text: '`[a](file:///p)`' } + ]); + }); + + it('tokenizes badges and code spans side by side', () => { + expect(tokenizeContent('[a](file:///p) `x`')).toEqual([ + { kind: 'badge', name: 'a', path: '/p' }, + { kind: 'text', text: ' ' }, + { kind: 'code_inline', text: '`x`' } + ]); + }); +}); + +describe('containsCodeSpan', () => { + it('detects inline code', () => { + expect(containsCodeSpan('run `npm test` now')).toBe(true); + }); + + it('detects a fenced block with a language', () => { + expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true); + }); + + it('detects a fenced block without a language', () => { + expect(containsCodeSpan('```\ncode\n```')).toBe(true); + }); + + it('ignores unclosed fences and lone backticks', () => { + expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false); + expect(containsCodeSpan('run `npm test')).toBe(false); + expect(containsCodeSpan('``')).toBe(false); + }); + + it('ignores plain text and mention links', () => { + expect(containsCodeSpan('hello world')).toBe(false); + expect(containsCodeSpan('[a](file:///p)')).toBe(false); + }); +}); + +describe('isOffsetInCodeBlock', () => { + const BLOCK = '```js\nconst a = 1;\n```'; + + it('is false with no fences in the buffer', () => { + expect(isOffsetInCodeBlock('hello world', 5)).toBe(false); + expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false); + }); + + it('is true right after the opening fence, before any content', () => { + expect(isOffsetInCodeBlock('```', 3)).toBe(true); + expect(isOffsetInCodeBlock('```js', 5)).toBe(true); + }); + + it('is true inside a still-open block while it is being typed', () => { + const open = '```js\nconst a = 1;'; + + expect(isOffsetInCodeBlock(open, open.length)).toBe(true); + }); + + it('is true inside a closed block and false outside it', () => { + expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true); + expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false); + expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false); + expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false); + }); + + it('toggles per fence across multiple blocks', () => { + const two = BLOCK + '\ntext\n' + BLOCK; + const secondBlock = two.lastIndexOf(BLOCK); + + expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false); + expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true); + expect(isOffsetInCodeBlock(two, two.length)).toBe(false); + }); +}); diff --git a/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts b/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts new file mode 100644 index 000000000..03eb974e5 --- /dev/null +++ b/tools/ui/tests/unit/chat-form-input-rich-word-jump.test.ts @@ -0,0 +1,83 @@ +import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils'; +import { describe, expect, it } from 'vitest'; + +// Layout of `hello [docs](file:///a/b) world foo`: +// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34 +const BADGE = '[docs](file:///a/b)'; +const SOURCE = `hello ${BADGE} world foo`; +const BADGE_START = 6; +const BADGE_END = 25; + +describe('badgeAwareWordJump', () => { + it('returns null when the buffer has no badge', () => { + expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull(); + expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull(); + }); + + it('jumps forward onto a badge landing at its end, not the next word', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END); + }); + + it('jumps forward from the space before a badge landing at its end', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END); + }); + + it('jumps backward over a badge landing at its start', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START); + }); + + it('jumps backward from the next word onto the badge start', () => { + // caret at the start of "world" + expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START); + }); + + it('returns null for jumps that cross no badge', () => { + // forward over "hello" only + expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull(); + // backward over "foo" only + expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull(); + // backward away from the badge (over "hello") + expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull(); + }); + + it('treats a leading badge as one word in both directions', () => { + const source = `${BADGE} rest`; + + expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length); + expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0); + }); + + it('treats adjacent badges as separate words', () => { + // each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)" + const source = '[a](file:///x)[b](file:///y)'; + + expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14); + expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28); + expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14); + expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0); + }); + + it('jumps over a badge following punctuation', () => { + // "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27 + const source = `foo, ${BADGE} bar`; + + expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull(); + expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24); + }); +}); + +describe('leadingBadgeEdgeOffset', () => { + it('returns 0 when the caret sits exactly at a leading badge end', () => { + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0); + }); + + it('returns null when the caret is anywhere else', () => { + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull(); + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull(); + }); + + it('returns null when the buffer does not start with a badge', () => { + expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull(); + expect(leadingBadgeEdgeOffset('', 0)).toBeNull(); + }); +}); diff --git a/tools/ui/tests/unit/contenteditable-tokenizer.test.ts b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts deleted file mode 100644 index 27a22aab9..000000000 --- a/tools/ui/tests/unit/contenteditable-tokenizer.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils'; -import { describe, expect, it } from 'vitest'; - -describe('tokenizeContent', () => { - it('tokenizes a plain text buffer with no badges', () => { - expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]); - }); - - it('tokenizes a single badge', () => { - expect(tokenizeContent('[docs](file:///a/b)')).toEqual([ - { kind: 'badge', name: 'docs', path: '/a/b' } - ]); - }); - - it('tokenizes text around a single badge', () => { - expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([ - { kind: 'text', text: 'hello ' }, - { kind: 'badge', name: 'docs', path: '/a/b' }, - { kind: 'text', text: ' world' } - ]); - }); - - it('tokenizes adjacent badges as separate tokens', () => { - expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([ - { kind: 'badge', name: 'a', path: '/x' }, - { kind: 'badge', name: 'b', path: '/y' } - ]); - }); - - it('leaves non-file links untouched in the stream', () => { - expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([ - { kind: 'text', text: 'see [foo](https://example.com) for details' } - ]); - }); - - it('recognizes badges whose path contains spaces (macOS screenshots)', () => { - const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; - const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, - { kind: 'text', text: ' ' } - ]); - }); - - it('recognizes badges whose path lives in the macOS temp folder', () => { - const path = - '/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png'; - const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path }, - { kind: 'text', text: ' ' } - ]); - }); - - it('keeps text around a badge with spaces in the path', () => { - const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; - const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'text', text: 'see ' }, - { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, - { kind: 'text', text: ' done' } - ]); - }); - - it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => { - const path = '/Users/foo/Screenshot (1).png'; - const source = `[Screenshot (1).png](file://${path}) `; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'badge', name: 'Screenshot (1).png', path }, - { kind: 'text', text: ' ' } - ]); - }); - - it('recognizes badges whose folder name is wrapped in parentheses', () => { - const path = '/Users/foo/Project (Stuff)/main.rs'; - const source = `[main.rs](file://${path}) `; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'badge', name: 'main.rs', path }, - { kind: 'text', text: ' ' } - ]); - }); - - it('recognizes adjacent badges back-to-back with no separator', () => { - const source = '[a](file:///p)[b](file:///q)'; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'badge', name: 'a', path: '/p' }, - { kind: 'badge', name: 'b', path: '/q' } - ]); - }); - - it('tokenizes inline code with the backticks included', () => { - expect(tokenizeContent('run `npm test` now')).toEqual([ - { kind: 'text', text: 'run ' }, - { kind: 'inlineCode', text: '`npm test`' }, - { kind: 'text', text: ' now' } - ]); - }); - - it('tokenizes a fenced code block without a language', () => { - const source = 'before\n```\nconst a = 1;\n```\nafter'; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'text', text: 'before\n' }, - { kind: 'codeBlock', text: '```\nconst a = 1;\n```' }, - { kind: 'text', text: '\nafter' } - ]); - }); - - it('tokenizes a fenced code block with a language', () => { - const source = '```js\nconst a = 1;\n```'; - - expect(tokenizeContent(source)).toEqual([ - { kind: 'codeBlock', text: '```js\nconst a = 1;\n```' } - ]); - }); - - it('prefers the fenced block over inline spans at triple backticks', () => { - expect(tokenizeContent('```a``` ```b```')).toEqual([ - { kind: 'codeBlock', text: '```a```' }, - { kind: 'text', text: ' ' }, - { kind: 'codeBlock', text: '```b```' } - ]); - }); - - it('leaves an unclosed fence as plain text', () => { - expect(tokenizeContent('```js\nconst a = 1;')).toEqual([ - { kind: 'text', text: '```js\nconst a = 1;' } - ]); - }); - - it('leaves an unclosed inline backtick as plain text', () => { - expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]); - }); - - it('does not recognize badges inside code spans', () => { - expect(tokenizeContent('`[a](file:///p)`')).toEqual([ - { kind: 'inlineCode', text: '`[a](file:///p)`' } - ]); - }); - - it('tokenizes badges and code spans side by side', () => { - expect(tokenizeContent('[a](file:///p) `x`')).toEqual([ - { kind: 'badge', name: 'a', path: '/p' }, - { kind: 'text', text: ' ' }, - { kind: 'inlineCode', text: '`x`' } - ]); - }); -}); - -describe('containsCodeSpan', () => { - it('detects inline code', () => { - expect(containsCodeSpan('run `npm test` now')).toBe(true); - }); - - it('detects a fenced block with a language', () => { - expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true); - }); - - it('detects a fenced block without a language', () => { - expect(containsCodeSpan('```\ncode\n```')).toBe(true); - }); - - it('ignores unclosed fences and lone backticks', () => { - expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false); - expect(containsCodeSpan('run `npm test')).toBe(false); - expect(containsCodeSpan('``')).toBe(false); - }); - - it('ignores plain text and mention links', () => { - expect(containsCodeSpan('hello world')).toBe(false); - expect(containsCodeSpan('[a](file:///p)')).toBe(false); - }); -}); - -describe('isOffsetInCodeBlock', () => { - const BLOCK = '```js\nconst a = 1;\n```'; - - it('is false with no fences in the buffer', () => { - expect(isOffsetInCodeBlock('hello world', 5)).toBe(false); - expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false); - }); - - it('is true right after the opening fence, before any content', () => { - expect(isOffsetInCodeBlock('```', 3)).toBe(true); - expect(isOffsetInCodeBlock('```js', 5)).toBe(true); - }); - - it('is true inside a still-open block while it is being typed', () => { - const open = '```js\nconst a = 1;'; - - expect(isOffsetInCodeBlock(open, open.length)).toBe(true); - }); - - it('is true inside a closed block and false outside it', () => { - expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true); - expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false); - expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false); - expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false); - }); - - it('toggles per fence across multiple blocks', () => { - const two = BLOCK + '\ntext\n' + BLOCK; - const secondBlock = two.lastIndexOf(BLOCK); - - expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false); - expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true); - expect(isOffsetInCodeBlock(two, two.length)).toBe(false); - }); -}); diff --git a/tools/ui/tests/unit/contenteditable-word-jump.test.ts b/tools/ui/tests/unit/contenteditable-word-jump.test.ts deleted file mode 100644 index 03eb974e5..000000000 --- a/tools/ui/tests/unit/contenteditable-word-jump.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils'; -import { describe, expect, it } from 'vitest'; - -// Layout of `hello [docs](file:///a/b) world foo`: -// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34 -const BADGE = '[docs](file:///a/b)'; -const SOURCE = `hello ${BADGE} world foo`; -const BADGE_START = 6; -const BADGE_END = 25; - -describe('badgeAwareWordJump', () => { - it('returns null when the buffer has no badge', () => { - expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull(); - expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull(); - }); - - it('jumps forward onto a badge landing at its end, not the next word', () => { - expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END); - }); - - it('jumps forward from the space before a badge landing at its end', () => { - expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END); - }); - - it('jumps backward over a badge landing at its start', () => { - expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START); - }); - - it('jumps backward from the next word onto the badge start', () => { - // caret at the start of "world" - expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START); - }); - - it('returns null for jumps that cross no badge', () => { - // forward over "hello" only - expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull(); - // backward over "foo" only - expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull(); - // backward away from the badge (over "hello") - expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull(); - }); - - it('treats a leading badge as one word in both directions', () => { - const source = `${BADGE} rest`; - - expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length); - expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0); - }); - - it('treats adjacent badges as separate words', () => { - // each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)" - const source = '[a](file:///x)[b](file:///y)'; - - expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14); - expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28); - expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14); - expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0); - }); - - it('jumps over a badge following punctuation', () => { - // "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27 - const source = `foo, ${BADGE} bar`; - - expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull(); - expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24); - }); -}); - -describe('leadingBadgeEdgeOffset', () => { - it('returns 0 when the caret sits exactly at a leading badge end', () => { - expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0); - }); - - it('returns null when the caret is anywhere else', () => { - expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull(); - expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull(); - }); - - it('returns null when the buffer does not start with a badge', () => { - expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull(); - expect(leadingBadgeEdgeOffset('', 0)).toBeNull(); - }); -});