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
This commit is contained in:
Aleksander Grygier
2026-08-07 20:40:10 +02:00
committed by GitHub
parent 1621a3d388
commit fc6545d322
27 changed files with 3497 additions and 110 deletions
+36 -1
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -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();
});
});
+3 -31
View File
@@ -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
});
});
});
@@ -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();
});
});