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();
+ });
+});