From fc6545d322a9ea6643f77439b31b66f403ba2cad Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 7 Aug 2026 20:40:10 +0200 Subject: [PATCH] allozaur/feat/chat form contenteditable (#26717) * feat: Add contenteditable tokenizer for badge/code-chip chat input * feat: Add source-space undo/redo history for the rich input * feat: Split text glued to a closing code fence onto its own line * feat: Add ChatFormContenteditable rich input renderer * feat : wire the contenteditable into ChatForm with auto-switch gating --- .../app/chat/ChatForm/ChatForm.svelte | 133 ++- .../ChatForm/ChatFormContenteditable.svelte | 788 ++++++++++++++++ .../ChatFormMentionPicker.svelte | 13 +- .../app/chat/ChatForm/ChatFormTextarea.svelte | 3 +- tools/ui/src/lib/components/app/chat/index.ts | 16 +- .../MarkdownContent/MarkdownContent.svelte | 9 +- .../MarkdownContent/markdown-content.css | 9 +- .../plugins/rehype/file-badge.ts | 4 +- tools/ui/src/lib/constants/css-classes.ts | 5 +- tools/ui/src/lib/constants/mention-badge.ts | 15 +- tools/ui/src/lib/enums/keyboard.enums.ts | 1 - tools/ui/src/lib/utils/code.ts | 50 + .../lib/utils/contenteditable-tokenizer.ts | 890 ++++++++++++++++++ tools/ui/src/lib/utils/index.ts | 29 +- tools/ui/src/lib/utils/mention-badge.ts | 23 +- tools/ui/src/lib/utils/source-history.ts | 48 + ...form-contenteditable-blocks.svelte.test.ts | 156 +++ ...t-form-contenteditable-undo.svelte.test.ts | 142 +++ .../chat-form-contenteditable.svelte.test.ts | 701 ++++++++++++++ .../chat-form-enter-code-block.svelte.test.ts | 112 +++ .../ChatFormContenteditableHarness.svelte | 27 + .../components/ChatFormTestWrapper.svelte | 12 + tools/ui/tests/unit/code.test.ts | 37 +- .../unit/contenteditable-tokenizer.test.ts | 205 ++++ .../unit/contenteditable-word-jump.test.ts | 80 ++ tools/ui/tests/unit/mention-badge.test.ts | 34 +- tools/ui/tests/unit/source-history.test.ts | 65 ++ 27 files changed, 3497 insertions(+), 110 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte create mode 100644 tools/ui/src/lib/utils/contenteditable-tokenizer.ts create mode 100644 tools/ui/src/lib/utils/source-history.ts create mode 100644 tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts create mode 100644 tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts create mode 100644 tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts create mode 100644 tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts create mode 100644 tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte create mode 100644 tools/ui/tests/client/components/ChatFormTestWrapper.svelte create mode 100644 tools/ui/tests/unit/contenteditable-tokenizer.test.ts create mode 100644 tools/ui/tests/unit/contenteditable-word-jump.test.ts create mode 100644 tools/ui/tests/unit/source-history.test.ts 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 fa68765ef..1df125708 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -2,6 +2,7 @@ import { ChatAttachmentsList, ChatFormActions, + ChatFormContenteditable, ChatFormFileInputInvisible, ChatFormMcpResourcesList, ChatFormPickers, @@ -46,10 +47,12 @@ } from '$lib/types'; import { buildMentionInsertion, + containsCodeSpan, + containsFileMentionLink, findCommandToken, findMentionToken, isIMEComposing, - mentionLinkEndingAt, + isOffsetInCodeBlock, parseClipboardContent, uuid } from '$lib/utils'; @@ -109,12 +112,28 @@ onValueChange }: Props = $props(); + // Component References + // Shared handle of the two input renderers (textarea + contenteditable). + type ChatInputHandle = { + focus(): void; + resetHeight(): void; + getElement(): HTMLElement | undefined; + getCaretOffset(): number; + setCaretOffset(offset: number): void; + }; + let audioRecorder: AudioRecorder | undefined; let chatFormActionsRef: ChatFormActions | undefined = $state(undefined); let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined); let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined = $state(undefined); - let inputRef: ChatFormTextarea | undefined = $state(undefined); + let inputRef: ChatInputHandle | undefined = $state(undefined); + + // Render-mode gate: the plain textarea by default, the contenteditable + // while the buffer carries a `file://` mention link or a complete code + // span (badges and code chips need a DOM the textarea cannot provide). + // Demotes back once neither remains. + let useContenteditable = $state(false); // Audio Recording State let isRecording = $state(false); @@ -201,6 +220,34 @@ ); let canSubmit = $derived(value.trim().length > 0 || hasAttachments); + // Caret offset restored after a renderer swap. Callers that mutate + // `value` themselves (e.g. the mention picker) pin the target offset + // BEFORE the assignment; otherwise the swap effect snapshots the + // current caret. + let pendingCaretOffset = 0; + let caretOffsetPinned = false; + + function queueCaretRestore() { + queueMicrotask(() => { + inputRef?.focus(); + inputRef?.setCaretOffset(pendingCaretOffset); + caretOffsetPinned = false; + }); + } + + $effect(() => { + const wantContenteditable = + containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? ''); + if (useContenteditable === wantContenteditable) return; + + if (!caretOffsetPinned) { + pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length; + } + + useContenteditable = wantContenteditable; + queueCaretRestore(); + }); + onMount(() => { recordingSupported = isAudioRecordingSupported(); audioRecorder = new AudioRecorder(); @@ -252,25 +299,19 @@ return; } - // Backspace at a mention link's end deletes the whole token at once. - if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) { - const el = inputRef?.getElement(); - if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) { - const link = mentionLinkEndingAt(value, el.selectionStart); - if (link) { - event.preventDefault(); - value = value.slice(0, link.start) + value.slice(link.end); - onValueChange?.(value); - queueMicrotask(() => inputRef?.setCaretOffset(link.start)); - return; - } - } - } - if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { const isModifier = event.ctrlKey || event.metaKey; const sendOnEnter = currentConfig.sendOnEnter !== false; + // Caret inside a fenced code block (closed, or still open + // while being typed): Enter adds a line, never submits. The + // contenteditable consumes this case locally; this gate + // covers the plain textarea, where skipping submit lets the + // native newline through. + if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) { + return; + } + if (sendOnEnter || isModifier) { event.preventDefault(); @@ -444,14 +485,20 @@ const built = buildMentionInsertion(entry, value, token); if (!built) return; + // Pin the post-insertion caret BEFORE the swap effect runs; + // otherwise the effect clobbers it with the textarea's selection + // at promotion time (browser-dependent: usually reset to 0). + pendingCaretOffset = built.caretOffset; + caretOffsetPinned = true; + value = built.newValue; onValueChange?.(built.newValue); - // bind:value applies on the next microtask; restore the caret after. - queueMicrotask(() => { - inputRef?.focus(); - inputRef?.setCaretOffset(built.caretOffset); - }); + // Already in contenteditable mode: no renderer flip, so the swap + // effect's caret restore never runs. + if (useContenteditable) { + queueCaretRestore(); + } } async function handleMicClick() { @@ -541,19 +588,35 @@
- { - pickers.handleInput(); - onValueChange?.(value); - }} - onPaste={handlePaste} - {disabled} - {placeholder} - /> + {#if useContenteditable} + { + pickers.handleInput(); + onValueChange?.(value); + }} + onPaste={handlePaste} + {disabled} + {placeholder} + /> + {:else} + { + pickers.handleInput(); + onValueChange?.(value); + }} + onPaste={handlePaste} + {disabled} + {placeholder} + /> + {/if} {#if mcpHasResourceAttachments()} + import { onDestroy, onMount, untrack } from 'svelte'; + import { mode } from 'mode-watcher'; + import githubDarkCss from 'highlight.js/styles/github-dark.css?inline'; + import githubLightCss from 'highlight.js/styles/github.css?inline'; + import { isMobile } from '$lib/stores/viewport.svelte'; + import { ColorMode } from '$lib/enums'; + import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants'; + import { + badgeAwareWordJump, + buildFragment, + domMatchesTokens, + highlightCode, + isIMEComposing, + isOffsetInCodeBlock, + leadingBadgeEdgeOffset, + rangeToTextOffset, + serializeContent, + SourceHistory, + stripBlockBoundaryLineBreaks, + syncCodeBlockHatches, + tokenizeContent, + textOffsetToRange + } from '$lib/utils'; + import type { ContentToken, SourceHistoryEntry } from '$lib/utils'; + + 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: ContentToken[]) { + 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(TRIM_LEADING_PADDING_REGEX)?.[0] ?? ''; + const trailing = content.match(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( + { value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) }, + 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 = { + value: lastEmittedValue, + caret: rangeToTextOffset(rootElement, safeRange()) + }; + 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 { text: source.slice(start, end), range }; + } + + 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/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte index 1debbe8e0..07cbaf492 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte @@ -2,7 +2,7 @@ import { File, Folder } from '@lucide/svelte'; import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils'; import { toolsStore } from '$lib/stores/tools.svelte'; - import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums'; + import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums'; import { isMobile } from '$lib/stores/viewport.svelte'; import { config } from '$lib/stores/settings.svelte'; import * as Popover from '$lib/components/ui/popover'; @@ -162,6 +162,17 @@ } export function handleKeydown(event: KeyboardEvent): boolean { + // Always consume Enter while the picker is open - even with no + // result yet (skeletons) or no matches - so the chat form's + // Enter-to-submit never fires mid-search. + if (isOpen && event.key === KeyboardKey.ENTER) { + event.preventDefault(); + if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) { + handleSelect(displayedItems[nav.hoveredIndex]); + } + return true; + } + return nav.handleKeydown(event); } diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte index ab297af4f..8ef56d7ee 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte @@ -48,7 +48,8 @@ } } - // Plain-text caret offsets for the picker/paste/mention-splice flows. + // 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; diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index b7a2eff5d..04b1d915f 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. * * **Architecture:** - * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts + * - Composes ChatFormTextarea (or ChatFormContenteditable 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.) @@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; /** - * Auto-resizing textarea with IME composition support. Mention links stay - * plain markdown text in the input; the chip rendering happens in the - * message view via the rehype file-badge plugin. + * 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. + */ +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'; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index be6041b63..6ea4ac78e 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -34,7 +34,8 @@ preprocessLaTeX, getImageErrorFallbackHtml, copyCodeToClipboard, - copyToClipboard + copyToClipboard, + splitGluedClosingCodeFences } from '$lib/utils'; import { IMAGE_NOT_ERROR_BOUND_SELECTOR, @@ -342,7 +343,11 @@ * Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity. * @param markdown - The raw markdown string to process */ - async function processMarkdown(markdown: string) { + async function processMarkdown(rawMarkdown: string) { + // Text glued to a closing code fence is not a fence to the parser - + // the block would swallow it. Split it onto its own line first. + const markdown = splitGluedClosingCodeFences(rawMarkdown); + // Early exit if content unchanged (can happen with rapid coalescing) if (markdown === previousContent) { return; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index 41813f4fd..cada489ca 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) { /* Code blocks */ .markdown-content :global(.code-block-wrapper) { - margin: 1.5rem 0; border-radius: 0.75rem; overflow: hidden; border: 1px solid color-mix(in oklch, var(--border) 30%, transparent); @@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) { max-height: var(--max-message-height); } +.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) { + margin-top: 1rem; +} + +.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) { + margin-bottom: 1rem; +} + .markdown-content:global(.dark) :global(.code-block-wrapper) { border-color: color-mix(in oklch, var(--border) 20%, transparent); } diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts index 4459e2ba7..9f9c59fc3 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts @@ -1,7 +1,7 @@ /** * Rehype plugin that rewrites `file://` markdown anchors into the inline - * @-mention chip, reusing the visual contract from - * `$lib/constants/mention-badge`. + * mention chip, sharing the class string with the contenteditable + * tokenizer via `$lib/constants/mention-badge`. * * The chip is presentational: `file://` navigation is blocked from * http(s) pages, so the anchor becomes a plain `` (no link role, diff --git a/tools/ui/src/lib/constants/css-classes.ts b/tools/ui/src/lib/constants/css-classes.ts index 095b82e41..4e3310544 100644 --- a/tools/ui/src/lib/constants/css-classes.ts +++ b/tools/ui/src/lib/constants/css-classes.ts @@ -19,8 +19,9 @@ export const PANEL_CLASSES = ` export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80'; export const DIALOG_SUBMENU_CONTENT = 'w-60'; -/** Selects the chat-form input to restore focus after model actions. */ -export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea'; +/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */ +export const CHAT_INPUT_FOCUS_SELECTOR = + '[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]'; /** Default Tailwind size class for inline icon components (lucide, etc.). */ export const ICON_CLASS_DEFAULT = 'h-4 w-4'; diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.ts index b0026eb33..d60727ba2 100644 --- a/tools/ui/src/lib/constants/mention-badge.ts +++ b/tools/ui/src/lib/constants/mention-badge.ts @@ -1,8 +1,9 @@ /** - * Visual contract for message @-mention badges. Svelte cannot be mounted - * from a hast tree, so the rehype file-badge plugin emits the shared class - * string below; keeping it here as a literal lets Tailwind's source - * scanner generate the utility classes. + * Shared visual contract between the two DOM-only badge paths (the + * contenteditable tokenizer + the rehype plugin). Svelte cannot be + * mounted at the per-keystroke tokenizer hot path nor from a hast tree, + * so both emit the badge with the same class string literal; Tailwind's + * scanner picks it up in both sources. */ export const MENTION_BADGE_CLASSNAME = 'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground'; @@ -10,8 +11,10 @@ export const MENTION_BADGE_CLASSNAME = export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0'; /** - * SVG attributes shared by the hast-built badge icons; the rehype plugin - * spreads them onto the `` `properties`. + * SVG attributes shared by the DOM-built and hast-built badge icons. + * The tokenizer applies them via `setAttribute`, the rehype plugin + * spreads them onto the hast `` `properties`; string values are + * valid for both. */ export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly> = { xmlns: 'http://www.w3.org/2000/svg', diff --git a/tools/ui/src/lib/enums/keyboard.enums.ts b/tools/ui/src/lib/enums/keyboard.enums.ts index 918bc57c5..735d3e4b4 100644 --- a/tools/ui/src/lib/enums/keyboard.enums.ts +++ b/tools/ui/src/lib/enums/keyboard.enums.ts @@ -8,7 +8,6 @@ export enum KeyboardKey { ARROW_DOWN = 'ArrowDown', ARROW_LEFT = 'ArrowLeft', ARROW_RIGHT = 'ArrowRight', - BACKSPACE = 'Backspace', TAB = 'Tab', B_LOWER = 'b', D_LOWER = 'd', diff --git a/tools/ui/src/lib/utils/code.ts b/tools/ui/src/lib/utils/code.ts index 35f3877f4..4361f311b 100644 --- a/tools/ui/src/lib/utils/code.ts +++ b/tools/ui/src/lib/utils/code.ts @@ -17,6 +17,56 @@ export interface IncompleteCodeBlock { openingIndex: number; } +// A fence line: up to 3 leading spaces (CommonMark), 3+ backticks, then +// whatever trails on the same line. +const FENCE_LINE_REGEX = /^ {0,3}(`{3,})(.*)$/; + +/** + * Splits text glued to a closing code fence onto its own line: + * + * ```ts + * let foo = 'bar'; + * ```create this file on ... + * + * A closing fence with trailing text is not a fence to the markdown + * parser, so the block would swallow the text as code. The chat form + * normally keeps the fence on its own line, but older messages and + * hand-pasted content can carry the glued form. + * + * Only trailing text containing whitespace is split: a single word + * after the backticks inside a fenced block is more likely nested + * markdown (a ```python example inside a ```md block) than glued prose. + */ +export function splitGluedClosingCodeFences(markdown: string): string { + if (!markdown.includes('```')) return markdown; + + const lines = markdown.split(NEWLINE); + let inside = false; + let changed = false; + + for (let i = 0; i < lines.length; i++) { + const match = FENCE_LINE_REGEX.exec(lines[i]); + if (!match) continue; + + if (!inside) { + inside = true; + continue; + } + + inside = false; + + const trailing = match[2]; + if (trailing.includes('`') || !/\s/.test(trailing)) continue; + + lines[i] = lines[i].slice(0, lines[i].length - trailing.length); + lines.splice(i + 1, 0, trailing.trim()); + i++; + changed = true; + } + + return changed ? lines.join(NEWLINE) : markdown; +} + /** * Strips empty lines (whitespace-only) from the start and end of code. * diff --git a/tools/ui/src/lib/utils/contenteditable-tokenizer.ts b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts new file mode 100644 index 000000000..0f136bdbe --- /dev/null +++ b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts @@ -0,0 +1,890 @@ +/** + * 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 { settingsStore } from '$lib/stores/settings.svelte'; +import { toolsStore } from '$lib/stores/tools.svelte'; + +export type ContentToken = + | { kind: 'text'; text: string } + | { kind: 'badge'; name: string; path: string } + | { kind: 'inlineCode'; text: string } + | { kind: 'codeBlock'; text: string }; + +// 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): ContentToken[] { + const tokens: ContentToken[] = []; + 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: 'codeBlock', text: match[1] } + : { kind: 'inlineCode', 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: ContentToken[]) { + 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: 'text', text: input.slice(cursor, start) }); + } + + tokens.push({ kind: 'badge', name, path }); + cursor = start + whole.length; + } + + if (cursor < input.length) { + tokens.push({ kind: '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: ContentToken[]): boolean { + const expected = tokens.filter((token) => token.kind !== '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 !== 'badge') return false; + if (token.name !== (el.dataset.mentionName ?? '')) return false; + if (token.path !== (el.dataset.mentionPath ?? '')) return false; + continue; + } + + const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode'; + if (token.kind !== codeKind) return false; + if ( + (token.kind === 'inlineCode' || token.kind === 'codeBlock') && + 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 { node, index } = 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 { node, index } = 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 = { node: parentNode, index: elIndex + 1 }; + 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/mention-badge`. + */ +export function buildFragment(tokens: ContentToken[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + + if (token.kind === '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 === 'codeBlock' && text.startsWith('\n')) { + text = text.slice(1); + } + if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) { + text = text.slice(0, -1); + } + if (text.length === 0) continue; + + fragment.appendChild(document.createTextNode(text)); + continue; + } + + if (token.kind === 'inlineCode' || token.kind === 'codeBlock') { + const code = document.createElement('code'); + code.dataset.codeToken = token.kind === 'codeBlock' ? '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 === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length; + if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]); + masked += token.kind === '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 !== '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 41428a1c5..6703da373 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -33,6 +33,7 @@ export { export { highlightCode, detectIncompleteCodeBlock, + splitGluedClosingCodeFences, trimCodePadding, type IncompleteCodeBlock } from './code'; @@ -205,9 +206,30 @@ export { type CommandDismissSnapshot } from './command-token'; -// Mention-chip visual contract shared by the rehype file-badge plugin, -// plus the `[name](file://...)` link helpers the mention picker splices in +// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM) export { + tokenizeContent, + containsCodeSpan, + isOffsetInCodeBlock, + domMatchesTokens, + syncCodeBlockHatches, + stripBlockBoundaryLineBreaks, + serializeContent, + buildFragment, + rangeToTextOffset, + textOffsetToRange, + badgeAwareWordJump, + leadingBadgeEdgeOffset, + type ContentToken +} from './contenteditable-tokenizer'; + +// Source-space undo/redo history for the chat-form contenteditable +export { SourceHistory, type SourceHistoryEntry } from './source-history'; + +// Mention-badge visual contract (used by the contenteditable / rehype +// DOM paths that build the same chip without a Svelte mount) +export { + containsFileMentionLink, fileMentionLinkRe, encodeFileLinkPath, decodeFileLinkPath, @@ -218,8 +240,7 @@ export { MENTION_BADGE_FOLDER_ICON_PATHS, getMentionBadgeIconPaths, getMentionBadgeLabel, - buildMentionInsertion, - mentionLinkEndingAt + buildMentionInsertion } from './mention-badge'; // Agentic content utilities (structured section derivation) diff --git a/tools/ui/src/lib/utils/mention-badge.ts b/tools/ui/src/lib/utils/mention-badge.ts index c90f1f98e..ed0712b46 100644 --- a/tools/ui/src/lib/utils/mention-badge.ts +++ b/tools/ui/src/lib/utils/mention-badge.ts @@ -23,6 +23,10 @@ export function fileMentionLinkRe(flags = ''): RegExp { return new RegExp(FILE_MENTION_LINK_SOURCE, flags); } +export function containsFileMentionLink(value: string): boolean { + return fileMentionLinkRe().test(value); +} + // Escape each path segment for a markdown link destination (spaces/parens // break CommonMark); keeps the trailing slash that marks a directory. export function encodeFileLinkPath(path: string): string { @@ -60,25 +64,6 @@ export function getMentionBadgeLabel( return abbreviateHome(decoded, home); } -/** - * Extent of the mention link ending exactly at `caret`, so Backspace there - * deletes the whole `[name](file://...)` token in one keystroke instead of - * unraveling it character by character. Null when no link ends at `caret`. - */ -export function mentionLinkEndingAt( - value: string, - caret: number -): { start: number; end: number } | null { - const re = fileMentionLinkRe('g'); - let match: RegExpExecArray | null; - while ((match = re.exec(value)) !== null) { - const end = match.index + match[0].length; - if (end === caret) return { start: match.index, end }; - if (end > caret) break; - } - return null; -} - /** * Build the markdown link that replaces a mention token. Entry `path` is * already rooted, so `file://` + `/abs` yields the canonical `file:///`. diff --git a/tools/ui/src/lib/utils/source-history.ts b/tools/ui/src/lib/utils/source-history.ts new file mode 100644 index 000000000..3ad2d1298 --- /dev/null +++ b/tools/ui/src/lib/utils/source-history.ts @@ -0,0 +1,48 @@ +/** + * Source-space undo/redo history for the chat-form contenteditable, whose + * imperative DOM rebuilds destroy the browser's native undo stack. + * Entries record the state BEFORE an edit; edits within `groupWindowMs` + * extend the open group so a typing burst undoes as a unit, while + * structural edits (paste, mention insert, clear) pass `newGroup`. + */ + +export interface SourceHistoryEntry { + value: string; + caret: number; +} + +export class SourceHistory { + private undoStack: SourceHistoryEntry[] = []; + private redoStack: SourceHistoryEntry[] = []; + private lastPush = 0; + + constructor( + private limit = 100, + private groupWindowMs = 800 + ) {} + + push(entry: SourceHistoryEntry, now: number, newGroup = false): void { + if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) { + this.undoStack.push(entry); + if (this.undoStack.length > this.limit) this.undoStack.shift(); + } + this.lastPush = now; + this.redoStack = []; + } + + undo(current: SourceHistoryEntry): SourceHistoryEntry | null { + const entry = this.undoStack.pop(); + if (!entry) return null; + this.redoStack.push(current); + this.lastPush = 0; // the next edit after an undo starts a new group + return entry; + } + + redo(current: SourceHistoryEntry): SourceHistoryEntry | null { + const entry = this.redoStack.pop(); + if (!entry) return null; + this.undoStack.push(current); + this.lastPush = 0; + return entry; + } +} 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 new file mode 100644 index 000000000..4560a7a9c --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts @@ -0,0 +1,156 @@ +// 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 { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.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 new file mode 100644 index 000000000..a412bb166 --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts @@ -0,0 +1,142 @@ +// 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 { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.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, { key: 'z', ctrlKey: true }); + await tick(); + expect(undoEvent.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe(SOURCE); + + const redoEvent = keydown(root, { key: 'z', ctrlKey: true, 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, { key: 'y', ctrlKey: true }); + 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, { key: 'z', ctrlKey: true }); + 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, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abcd'); + + keydown(root, { key: 'z', ctrlKey: true }); + 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, { key: 'z', ctrlKey: true }); + 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, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + + type(root, 'e'); + await tick(); + keydown(root, { key: 'z', ctrlKey: true, 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 new file mode 100644 index 000000000..c90f8a83d --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts @@ -0,0 +1,701 @@ +// 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 { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { tick } from 'svelte'; +import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils'; +import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.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, { clipboardData: data, bubbles: true, cancelable: true }); + return { event, data }; +} + +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 { event, data } = 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 { event, data } = 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 { event, data } = 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 { event, data } = 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', { inputType: 'insertLineBreak', bubbles: true })); + 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', { inputType: 'insertLineBreak', bubbles: true })); + 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, { + value: BLOCK_SOURCE, + onKeydown + }); + 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, { + value: '```js\nconst a = 1;', + onKeydown + }); + 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, { + value: BLOCK_SOURCE + '\nafter', + onKeydown + }); + 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, { + value: BLOCK_SOURCE, + onKeydown + }); + 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, { + value: BLOCK_SOURCE, + onKeydown + }); + 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({ key: 'Enter', ctrlKey: true }) + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE); + }); + + it('forwards Enter inside an inline code span', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: 'run `npm test` now', + onKeydown + }); + 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 new file mode 100644 index 000000000..c5d2f126c --- /dev/null +++ b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts @@ -0,0 +1,112 @@ +// Guards the Enter-key contract of the chat form against the +// fenced-code-block flow: while the caret sits inside a fenced +// 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). + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { tick } from 'svelte'; +import { SETTINGS_KEYS } from '$lib/constants/settings-keys'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte'; + +function textareaIn(container: HTMLElement): HTMLTextAreaElement { + const el = container.querySelector('textarea'); + if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered'); + return el; +} + +describe('ChatForm Enter in code blocks', () => { + beforeEach(() => { + settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true); + }); + + it('adds a line after a still-open fence instead of submitting', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(textarea.value).toBe('```\n'); + }); + + it('keeps adding lines while the block stays open', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```js'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('const a = 1;'); + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(textarea.value).toBe('```js\nconst a = 1;\n'); + }); + + it('submits when the caret is before the opening fence', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + textarea.setSelectionRange(0, 0); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('submits on Enter outside a code block', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('hello'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('submits on Ctrl+Enter even inside a code block', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + await userEvent.keyboard('{Control>}{Enter}{/Control}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte new file mode 100644 index 000000000..bfc567e62 --- /dev/null +++ b/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte @@ -0,0 +1,27 @@ + + + diff --git a/tools/ui/tests/client/components/ChatFormTestWrapper.svelte b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte new file mode 100644 index 000000000..78a431157 --- /dev/null +++ b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/tools/ui/tests/unit/code.test.ts b/tools/ui/tests/unit/code.test.ts index 7820c9807..99fad6044 100644 --- a/tools/ui/tests/unit/code.test.ts +++ b/tools/ui/tests/unit/code.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { highlightCode, trimCodePadding } from '$lib/utils/code'; +import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code'; describe('trimCodePadding', () => { it('removes a single leading newline', () => { @@ -101,3 +101,38 @@ describe('highlightCode', () => { expect(html).toBe('<script>a && b</script>'); }); }); + +describe('splitGluedClosingCodeFences', () => { + it('splits text glued to a closing fence onto its own line', () => { + const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)"; + expect(splitGluedClosingCodeFences(input)).toBe( + "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)" + ); + }); + + it('leaves a well-formed code block untouched', () => { + const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); + + it('leaves content without fences untouched', () => { + expect(splitGluedClosingCodeFences('hello world')).toBe('hello world'); + }); + + it('keeps nested markdown fences inside a block intact', () => { + const input = '```md\n# Example\n```python\nprint(1)\n```\n```'; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); + + it('splits every glued closing fence when several blocks are present', () => { + const input = '```ts\na\n```first words\n\n```js\nb\n```second words'; + expect(splitGluedClosingCodeFences(input)).toBe( + '```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words' + ); + }); + + it('leaves a still-open fence untouched', () => { + const input = '```ts\nlet foo = 1;'; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); +}); diff --git a/tools/ui/tests/unit/contenteditable-tokenizer.test.ts b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts new file mode 100644 index 000000000..8752415f0 --- /dev/null +++ b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils'; + +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 new file mode 100644 index 000000000..8e68950d4 --- /dev/null +++ b/tools/ui/tests/unit/contenteditable-word-jump.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils'; + +// 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/mention-badge.test.ts b/tools/ui/tests/unit/mention-badge.test.ts index 03a0d5ba4..f2c004182 100644 --- a/tools/ui/tests/unit/mention-badge.test.ts +++ b/tools/ui/tests/unit/mention-badge.test.ts @@ -3,12 +3,12 @@ import { MENTION_BADGE_FILE_ICON_PATHS, MENTION_BADGE_FOLDER_ICON_PATHS, buildMentionInsertion, + containsFileMentionLink, decodeFileLinkPath, encodeFileLinkPath, fileMentionLinkRe, getMentionBadgeIconPaths, - getMentionBadgeLabel, - mentionLinkEndingAt + getMentionBadgeLabel } from '$lib/utils'; import { FileMentionEntryType } from '$lib/enums'; @@ -35,6 +35,7 @@ describe('encodeFileLinkPath', () => { describe('fileMentionLinkRe', () => { it('matches a standard mention link', () => { expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true); + expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true); }); it('does not match non-file links', () => { @@ -171,32 +172,3 @@ describe('buildMentionInsertion', () => { expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull(); }); }); - -describe('mentionLinkEndingAt', () => { - const LINK = '[docs](file:///a/b)'; - - it('returns the extent when the caret is exactly at the link end', () => { - expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({ - start: 4, - end: 4 + LINK.length - }); - }); - - it('returns null when the caret is inside or past the link', () => { - expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull(); - expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull(); - }); - - it('returns null for non-file links and plain text', () => { - expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull(); - expect(mentionLinkEndingAt('plain', 5)).toBeNull(); - }); - - it('picks the link that ends at the caret when several exist', () => { - const value = `${LINK} and ${LINK}`; - expect(mentionLinkEndingAt(value, value.length)).toEqual({ - start: value.length - LINK.length, - end: value.length - }); - }); -}); diff --git a/tools/ui/tests/unit/source-history.test.ts b/tools/ui/tests/unit/source-history.test.ts new file mode 100644 index 000000000..b83030941 --- /dev/null +++ b/tools/ui/tests/unit/source-history.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { SourceHistory } from '$lib/utils'; + +describe('SourceHistory', () => { + it('coalesces pushes inside the group window into one undo step', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'a', caret: 1 }, 1200); + h.push({ value: 'ab', caret: 2 }, 1500); + + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + expect(h.undo({ value: '', caret: 0 })).toBeNull(); + }); + + it('starts a new group once the window has passed', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'abc', caret: 3 }, 2000); + + expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 }); + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + }); + + it('newGroup forces a separate entry even inside the window', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'abc', caret: 3 }, 1100, true); + + expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 }); + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + }); + + it('redo round-trips and a fresh push clears the redo stack', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + + const undone = h.undo({ value: 'abc', caret: 3 }); + expect(undone).toEqual({ value: '', caret: 0 }); + expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 }); + + h.undo({ value: 'abc', caret: 3 }); + h.push({ value: '', caret: 0 }, 5000); + expect(h.redo({ value: 'x', caret: 1 })).toBeNull(); + }); + + it('starts a new group on the first edit after an undo', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.undo({ value: 'abc', caret: 3 }); + + h.push({ value: '', caret: 0 }, 1200); + expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 }); + }); + + it('evicts the oldest entry past the limit', () => { + const h = new SourceHistory(2, 800); + h.push({ value: 'one', caret: 0 }, 1000); + h.push({ value: 'two', caret: 0 }, 2000); + h.push({ value: 'three', caret: 0 }, 3000); + + expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 }); + expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 }); + expect(h.undo({ value: 'two', caret: 0 })).toBeNull(); + }); +});