char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
27.3 KiB1074 linesraw
1/**
2 * Maps between the chat-form-input-rich's markdown source and the
3 * badge/code/text token stream the DOM is built from. A badge is one
4 * opaque source contribution (`[name](file://path)`); its own subtree
5 * is never walked, and the caret cannot land inside it, so offsets
6 * resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
7 * are EDITABLE, unlike badges: they carry the full source segment
8 * (backtick fences included) as their text, so their textContent
9 * serializes verbatim and source offsets map 1:1 to text offsets.
10 *
11 * The tokenizer emits a flat DOM (text nodes + badges + code spans),
12 * but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
13 * shapes). Serialization folds those back into `\n` so the source
14 * never diverges from what is on screen; both offset mappers
15 * understand the same shapes.
16 *
17 * The newline separating a fenced block from adjacent content is a
18 * SOURCE-level concept, never stored in the DOM: the block is
19 * display:block, so a leading `\n` in the following text node would
20 * render as a phantom empty line. Serialization synthesizes exactly
21 * one `\n` at every block boundary and `buildFragment` strips it from
22 * text tokens. A text node's own leading/trailing `\n` next to a
23 * block is an ADDITIONAL blank line.
24 */
25
26import {
27	decodeFileLinkPath,
28	fileMentionLinkRe,
29	getMentionBadgeIconPaths,
30	getMentionBadgeLabel
31} from './mention-badge';
32import {
33	CODE_TOKEN_ATTR,
34	MENTION_BADGE_CLASSNAME,
35	MENTION_BADGE_DATA_ATTRS,
36	MENTION_BADGE_ICON_CLASSNAME,
37	MENTION_BADGE_SVG_ATTRIBUTES,
38	SETTINGS_KEYS
39} from '$lib/constants';
40import { BooleanString, ChatFormInputRichTokenKind } from '$lib/enums';
41import { settingsStore } from '$lib/stores/settings/index.svelte';
42import { toolsStore } from '$lib/stores/tools.svelte';
43import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich';
44
45// Block wrappers browsers insert for newlines; each folds back into a
46// single `\n` during serialization.
47const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
48// `file://` is required so plain URLs stay as text; `)` terminates only
49// when not followed by whitespace or `[` (adjacent badges keep working).
50const MENTION_BADGE_RE = fileMentionLinkRe('g');
51
52function badgeSourceLength(name: string, path: string): number {
53	if (!name || !path) return 0;
54
55	return `[${name}](file://${path})`.length;
56}
57
58/**
59 * Recognize complete code spans. Fenced blocks (triple backticks,
60 * optional language, possibly multiline) take priority over inline
61 * spans (single backticks, single line, non-empty). Only CLOSED
62 * spans match: an unclosed fence stays plain text until the closing
63 * backticks land. The match includes the fences so the token's
64 * source length equals its rendered text length.
65 */
66const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
67
68/**
69 * Cheap gate check for `ChatForm`: does the buffer contain a
70 * complete code span (inline or fenced)? Used to promote the plain
71 * textarea to the chat-form-input-rich renderer.
72 */
73export function containsCodeSpan(value: string): boolean {
74	CODE_SPAN_RE.lastIndex = 0;
75
76	return CODE_SPAN_RE.test(value);
77}
78
79const CODE_FENCE_RE = /```/g;
80
81/**
82 * Is `offset` inside a fenced code block region? Toggle-based: an
83 * odd number of ``` fences before the offset means the position
84 * sits in block content. Unlike `containsCodeSpan` this also
85 * counts the still-OPEN fence while the user is typing a block
86 * (no closing ``` yet), so Enter can add a line instead of
87 * submitting the message.
88 */
89export function isOffsetInCodeBlock(source: string, offset: number): boolean {
90	let inside = false;
91
92	CODE_FENCE_RE.lastIndex = 0;
93
94	let match: RegExpExecArray | null;
95
96	while ((match = CODE_FENCE_RE.exec(source)) !== null) {
97		if (match.index + match[0].length > offset) break;
98
99		inside = !inside;
100	}
101
102	return inside;
103}
104
105/**
106 * Tokenize a markdown source value into the segments the
107 * chat-form-input-rich will render. Code spans are carved out first
108 * (their content is literal - a `file://` link inside backticks
109 * must NOT render as a badge), then plain text and badges
110 * interleave in the remaining gaps. Any whitespace after a badge
111 * stays in a plain text token so the round trip is byte-exact.
112 */
113export function tokenizeContent(input: string): ChatFormInputRichToken[] {
114	const tokens: ChatFormInputRichToken[] = [];
115
116	let cursor = 0;
117
118	CODE_SPAN_RE.lastIndex = 0;
119
120	let match: RegExpExecArray | null;
121
122	while ((match = CODE_SPAN_RE.exec(input)) !== null) {
123		const start = match.index;
124
125		if (start > cursor) {
126			pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
127		}
128
129		tokens.push(
130			match[1] !== undefined
131				? { kind: ChatFormInputRichTokenKind.CODE_BLOCK, text: match[1] }
132				: { kind: ChatFormInputRichTokenKind.CODE_INLINE, text: match[2] }
133		);
134		cursor = start + match[0].length;
135	}
136
137	if (cursor < input.length) {
138		pushTextAndBadgeTokens(input.slice(cursor), tokens);
139	}
140
141	return tokens;
142}
143
144/**
145 * Tokenize a code-free segment into text and badge tokens.
146 */
147function pushTextAndBadgeTokens(input: string, tokens: ChatFormInputRichToken[]) {
148	let cursor = 0;
149
150	MENTION_BADGE_RE.lastIndex = 0;
151
152	let match: RegExpExecArray | null;
153
154	while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
155		const [whole, name, path] = match;
156		const start = match.index;
157
158		if (start > cursor) {
159			tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor, start) });
160		}
161
162		tokens.push({ kind: ChatFormInputRichTokenKind.BADGE, name, path });
163		cursor = start + whole.length;
164	}
165
166	if (cursor < input.length) {
167		tokens.push({ kind: ChatFormInputRichTokenKind.TEXT, text: input.slice(cursor) });
168	}
169}
170
171function isCodeBlockElement(node: Node | null): node is HTMLElement {
172	return (
173		node instanceof HTMLElement &&
174		node.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
175	);
176}
177
178/**
179 * Serialize a chat-form-input-rich subtree back to source. `<br>` and block
180 * wrappers the browser inserted for newlines fold back into `\n` (a
181 * trailing `<br>` is the browser's caret placeholder, not a newline);
182 * any other element is transparent. Code spans serialize their
183 * textContent verbatim (fences included). One separator `\n` is
184 * synthesized at every fenced-block boundary (the DOM never stores
185 * it), and a `<br>` adjacent to a code block is an escape hatch, not
186 * a newline.
187 */
188export function serializeContent(root: HTMLElement): string {
189	let out = '';
190	let pendingBlockBoundary = false;
191
192	const walk = (parent: Node) => {
193		let first = true; // no source-contributing sibling seen yet
194
195		for (const child of Array.from(parent.childNodes)) {
196			if (child.nodeType === Node.TEXT_NODE) {
197				const text = child.textContent ?? '';
198
199				if (text.length > 0) {
200					if (pendingBlockBoundary) {
201						out += '\n';
202						pendingBlockBoundary = false;
203					}
204
205					out += text;
206					first = false;
207				}
208
209				continue;
210			}
211
212			if (child.nodeType !== Node.ELEMENT_NODE) continue;
213
214			const el = child as HTMLElement;
215
216			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
217				const name = el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '';
218				const path = el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '';
219
220				if (name && path) {
221					if (pendingBlockBoundary) {
222						out += '\n';
223						pendingBlockBoundary = false;
224					}
225
226					out += `[${name}](file://${path})`;
227					first = false;
228				}
229
230				continue;
231			}
232
233			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
234
235			if (codeToken !== null) {
236				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
237
238				if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
239
240				pendingBlockBoundary = false;
241				walk(el);
242				first = false;
243
244				if (isBlock) pendingBlockBoundary = true;
245
246				continue;
247			}
248
249			if (el.tagName === 'BR') {
250				const isHatch =
251					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
252
253				if (!isHatch && el.nextSibling) {
254					if (pendingBlockBoundary) {
255						out += '\n';
256						pendingBlockBoundary = false;
257					}
258
259					out += '\n';
260					first = false;
261				}
262
263				continue;
264			}
265
266			if (BLOCK_TAG_NAMES.has(el.tagName)) {
267				if (pendingBlockBoundary || !first) out += '\n';
268
269				pendingBlockBoundary = false;
270				walk(el);
271				first = false;
272
273				continue;
274			}
275
276			walk(el);
277
278			if (pendingBlockBoundary) first = false;
279		}
280	};
281
282	walk(root);
283
284	return out;
285}
286
287/**
288 * Compare the live DOM's non-text structure against a token stream.
289 * Only element contributions are compared (badges by name/path, code
290 * spans by kind and source segment): text nodes are owned by the
291 * browser between rebuilds, so their split/merge state is irrelevant.
292 * A mismatch means token boundaries shifted (a code span was just
293 * completed or broken) and the DOM needs a rebuild to restyle.
294 */
295export function domMatchesTokens(root: HTMLElement, tokens: ChatFormInputRichToken[]): boolean {
296	const expected = tokens.filter((token) => token.kind !== ChatFormInputRichTokenKind.TEXT);
297
298	let index = 0;
299
300	const walk = (parent: Node): boolean => {
301		for (const child of Array.from(parent.childNodes)) {
302			if (child.nodeType !== Node.ELEMENT_NODE) continue;
303
304			const el = child as HTMLElement;
305			const isBadge = el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE;
306			const isCode = el.getAttribute(CODE_TOKEN_ATTR) !== null;
307
308			if (!isBadge && !isCode) {
309				if (!walk(el)) return false;
310
311				continue;
312			}
313
314			const token = expected[index++];
315
316			if (!token) return false;
317
318			if (isBadge) {
319				if (token.kind !== ChatFormInputRichTokenKind.BADGE) return false;
320
321				if (token.name !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '')) return false;
322
323				if (token.path !== (el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? '')) return false;
324
325				continue;
326			}
327
328			const codeKind: ChatFormInputRichTokenKind =
329				el.getAttribute(CODE_TOKEN_ATTR) === ChatFormInputRichTokenKind.CODE_BLOCK
330					? ChatFormInputRichTokenKind.CODE_BLOCK
331					: ChatFormInputRichTokenKind.CODE_INLINE;
332
333			if (token.kind !== codeKind) return false;
334
335			if (
336				token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
337				token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
338			) {
339				if (token.text !== (el.textContent ?? '')) return false;
340			}
341		}
342
343		return true;
344	};
345
346	return walk(root) && index === expected.length;
347}
348
349/**
350 * Plain-text offset of a `Range` in the root; null range (selection
351 * lost) falls back to buffer length. Walked against the live DOM (not
352 * a clone) so a `<br>` keeps its trailing/not-trailing context. Code
353 * spans count their full textContent (fences included) and the caret
354 * may land inside them; synthesized block boundaries count one `\n`
355 * once the caret is past them.
356 */
357export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
358	if (!range) return serializeContent(root).length;
359
360	// A point is at/before the caret iff it falls inside [root start, caret].
361	const pre = range.cloneRange();
362
363	pre.selectNodeContents(root);
364	pre.setEnd(range.endContainer, range.endOffset);
365	const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
366
367	let total = 0;
368	let done = false;
369	// DOM position of a code block's synthesized after-boundary, set
370	// when walking past a block and consumed by the next contributing
371	// sibling (counts one `\n` once the caret is past it).
372	let pendingPoint: { node: Node; index: number } | null = null;
373
374	const walk = (parent: Node) => {
375		let first = true;
376
377		for (const child of Array.from(parent.childNodes)) {
378			if (done) return;
379
380			if (child.nodeType === Node.TEXT_NODE) {
381				const text = child.textContent ?? '';
382
383				if (text.length === 0) continue;
384
385				if (pendingPoint) {
386					const { index, node } = pendingPoint;
387
388					pendingPoint = null;
389
390					if (!atOrBeforeCaret(node, index)) {
391						done = true;
392
393						return;
394					}
395
396					total += 1;
397				}
398
399				if (!atOrBeforeCaret(child, 0)) {
400					done = true;
401
402					return;
403				}
404
405				if (range.endContainer === child) {
406					total += range.endOffset;
407					done = true;
408
409					return;
410				}
411
412				total += text.length;
413				first = false;
414
415				continue;
416			}
417
418			if (child.nodeType !== Node.ELEMENT_NODE) continue;
419
420			const el = child as HTMLElement;
421			const parentNode = el.parentNode as Node;
422			const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
423
424			if (pendingPoint) {
425				const { index, node } = pendingPoint;
426
427				pendingPoint = null;
428
429				if (!atOrBeforeCaret(node, index)) {
430					done = true;
431
432					return;
433				}
434
435				total += 1;
436			}
437
438			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
439				const len = badgeSourceLength(
440					el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
441					el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
442				);
443
444				if (len === 0) continue;
445
446				if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
447					done = true;
448
449					return;
450				}
451
452				total += len;
453				first = false;
454
455				continue;
456			}
457
458			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
459
460			if (codeToken !== null) {
461				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
462
463				if (isBlock && !first) {
464					if (!atOrBeforeCaret(el, 0)) {
465						done = true;
466
467						return;
468					}
469
470					total += 1;
471				}
472
473				walk(el);
474				first = false;
475
476				if (isBlock) pendingPoint = { index: elIndex + 1, node: parentNode };
477
478				continue;
479			}
480
481			if (el.tagName === 'BR') {
482				const isHatch =
483					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
484
485				if (isHatch || !el.nextSibling) continue;
486
487				if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
488					done = true;
489
490					return;
491				}
492
493				total += 1;
494				first = false;
495
496				continue;
497			}
498
499			if (BLOCK_TAG_NAMES.has(el.tagName)) {
500				if (!first) {
501					if (!atOrBeforeCaret(el, 0)) {
502						done = true;
503
504						return;
505					}
506
507					total += 1;
508				}
509
510				walk(el);
511				first = false;
512
513				continue;
514			}
515
516			const before = total;
517
518			walk(el);
519
520			if (total > before) first = false;
521		}
522	};
523
524	walk(root);
525
526	return total;
527}
528
529/**
530 * Materialize a token stream into a DOM subtree: text nodes for text
531 * tokens, `<span data-mention-badge="true">` elements for badges,
532 * `<code data-code-token>` elements for code spans. The badge's class
533 * string + inline SVG are shared with the rehype plugin via
534 * `$lib/constants`.
535 */
536export function buildFragment(tokens: ChatFormInputRichToken[]): DocumentFragment {
537	const fragment = document.createDocumentFragment();
538
539	for (let index = 0; index < tokens.length; index++) {
540		const token = tokens[index];
541
542		if (token.kind === ChatFormInputRichTokenKind.TEXT) {
543			let text = token.text;
544
545			// The separator \n at a fenced-block boundary is synthesized
546			// at serialization time; keeping it in the DOM would render a
547			// phantom empty line next to the block.
548			if (
549				tokens[index - 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
550				text.startsWith('\n')
551			) {
552				text = text.slice(1);
553			}
554
555			if (
556				tokens[index + 1]?.kind === ChatFormInputRichTokenKind.CODE_BLOCK &&
557				text.endsWith('\n')
558			) {
559				text = text.slice(0, -1);
560			}
561
562			if (text.length === 0) continue;
563
564			fragment.appendChild(document.createTextNode(text));
565
566			continue;
567		}
568
569		if (
570			token.kind === ChatFormInputRichTokenKind.CODE_INLINE ||
571			token.kind === ChatFormInputRichTokenKind.CODE_BLOCK
572		) {
573			const code = document.createElement('code');
574
575			code.setAttribute(CODE_TOKEN_ATTR, token.kind);
576			code.textContent = token.text;
577			fragment.appendChild(code);
578
579			continue;
580		}
581
582		// A leading badge gets an empty text node prepended: without a real
583		// text position at the buffer start, the spot before the badge is
584		// unreachable via keyboard (ArrowLeft/Home).
585		if (!fragment.lastChild) {
586			fragment.appendChild(document.createTextNode(''));
587		}
588
589		const badge = document.createElement('span');
590
591		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.BADGE, BooleanString.TRUE);
592		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.NAME, token.name);
593		badge.setAttribute(MENTION_BADGE_DATA_ATTRS.PATH, token.path);
594		badge.title = decodeFileLinkPath(token.path);
595		badge.className = MENTION_BADGE_CLASSNAME;
596		badge.contentEditable = 'false';
597
598		const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
599
600		for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
601			svg.setAttribute(attr, value);
602		}
603		for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
604			svg.classList.add(cls);
605		}
606
607		for (const d of getMentionBadgeIconPaths(token.path)) {
608			const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
609
610			path.setAttribute('d', d);
611			svg.appendChild(path);
612		}
613
614		const label = document.createElement('span');
615
616		label.classList.add('shrink-0', 'truncate');
617		label.textContent = getMentionBadgeLabel(
618			token.name,
619			decodeFileLinkPath(token.path),
620			settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
621			toolsStore.serverHome
622		);
623
624		badge.appendChild(svg);
625		badge.appendChild(label);
626		fragment.appendChild(badge);
627	}
628
629	return fragment;
630}
631
632// A sibling provides a reachable caret line when it is an element
633// (badge, another block, an existing hatch) or a non-empty text node.
634function hasLineBeside(node: Node | null): boolean {
635	if (!node) return false;
636
637	if (node.nodeType === Node.ELEMENT_NODE) return true;
638
639	return (node.textContent ?? '') !== '';
640}
641
642/**
643 * A code block at the END of the buffer needs an editable line after
644 * it: without one the caret cannot leave the block with
645 * ArrowDown/ArrowRight. A trailing `<br>` provides that line while
646 * staying transparent to serialization (skipped as a hatch), and is
647 * removed again once real content takes its place.
648 *
649 * No hatch is added BEFORE a leading block: the empty line above it
650 * is transient and managed by the component (created when the caret
651 * arrows onto it, removed when the caret leaves). A transient
652 * leading hatch found here is kept; the browser's lone placeholder
653 * `<br>` in an empty root is left untouched.
654 */
655export function syncCodeBlockHatches(root: HTMLElement) {
656	for (const child of Array.from(root.childNodes)) {
657		if (child.nodeName !== 'BR') continue;
658
659		const isPlaceholder = root.childNodes.length === 1;
660		const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
661		const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
662
663		// A hatch goes stale once real content takes over its line:
664		// content before a leading hatch, content after a trailing one,
665		// or a text node after the block already providing the line.
666		// A `<br>` with no code block around is a real newline (browser
667		// Shift+Enter shape) and stays.
668		let prevElement = child.previousSibling;
669
670		while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
671			prevElement = prevElement.previousSibling;
672		}
673		const nearBlock =
674			isCodeBlockElement(child.nextSibling) ||
675			isCodeBlockElement(child.previousSibling) ||
676			isCodeBlockElement(prevElement);
677
678		if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
679			child.remove();
680		}
681	}
682
683	for (const child of Array.from(root.childNodes)) {
684		if (!isCodeBlockElement(child)) continue;
685
686		if (!hasLineBeside(child.nextSibling)) {
687			child.after(document.createElement('br'));
688		}
689	}
690}
691
692/**
693 * Strip the separator and artificial newlines from an all-newline text
694 * node directly after a fenced block. Chromium's line break at the
695 * buffer end inserts an extra artificial `\n` so the new line has
696 * height, and the first `\n` after a block doubles as the fence's
697 * separator line (synthesized at serialization time). Removing both
698 * makes Shift+Enter after a block land the caret on the line directly
699 * below the block, like a plain textarea would.
700 *
701 * Only all-newline text nodes are touched: a node with real content
702 * carries intentional blank lines and is left alone. Returns true when
703 * the DOM changed.
704 */
705export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
706	let changed = false;
707
708	for (const child of Array.from(root.childNodes)) {
709		if (child.nodeType !== Node.TEXT_NODE) continue;
710
711		if (!isCodeBlockElement(child.previousSibling)) continue;
712
713		let text = child.textContent ?? '';
714
715		if (!/^\n{2,}$/.test(text)) continue;
716
717		text = text.slice(1);
718
719		const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
720
721		if (atBufferEnd) {
722			text = text.slice(0, -1);
723		}
724
725		child.textContent = text;
726		changed = true;
727	}
728
729	return changed;
730}
731
732const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
733
734/**
735 * Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
736 * when the jump crosses no badge and native word movement should handle
737 * it. Badge spans are masked to word characters, so a badge counts as
738 * exactly one word.
739 */
740export function badgeAwareWordJump(
741	source: string,
742	offset: number,
743	direction: 'forward' | 'backward'
744): number | null {
745	let masked = '';
746
747	const badgeSpans: Array<[number, number]> = [];
748
749	for (const token of tokenizeContent(source)) {
750		const len =
751			token.kind === ChatFormInputRichTokenKind.BADGE
752				? badgeSourceLength(token.name, token.path)
753				: token.text.length;
754
755		if (token.kind === ChatFormInputRichTokenKind.BADGE)
756			badgeSpans.push([masked.length, masked.length + len]);
757
758		masked += token.kind === ChatFormInputRichTokenKind.BADGE ? 'a'.repeat(len) : token.text;
759	}
760
761	if (badgeSpans.length === 0) return null;
762
763	const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
764	const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
765	const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
766	const n = masked.length;
767
768	let i = offset;
769
770	if (direction === 'forward') {
771		// Entering a badge completes the word phase at the badge's end edge.
772		if (!(i < n && isWord(i))) {
773			while (i < n && !isWord(i)) i++;
774		}
775
776		while (i < n && isWord(i)) {
777			const span = spanStartingAt(i);
778
779			if (span) {
780				i = span[1];
781
782				break;
783			}
784
785			i++;
786		}
787	} else {
788		if (!(i > 0 && isWord(i - 1))) {
789			while (i > 0 && !isWord(i - 1)) i--;
790		}
791
792		while (i > 0 && isWord(i - 1)) {
793			const span = spanEndingAt(i);
794
795			if (span) {
796				i = span[0];
797
798				break;
799			}
800
801			i--;
802		}
803	}
804
805	if (i === offset) return null;
806
807	const lo = Math.min(offset, i);
808	const hi = Math.max(offset, i);
809
810	return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
811}
812
813/**
814 * 0 when `caret` sits exactly at a leading badge's end edge, null
815 * otherwise. Plain ArrowLeft there has no native previous position, so
816 * the host snaps the caret to the buffer start manually.
817 */
818export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
819	const [first] = tokenizeContent(source);
820
821	if (!first || first.kind !== ChatFormInputRichTokenKind.BADGE) return null;
822
823	return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
824}
825
826/**
827 * Translate a plain-text offset into a degenerate `Range` at that
828 * position in the DOM; out-of-range offsets clamp to buffer end (before
829 * a trailing escape hatch, not after it). Zero offset lands BEFORE a
830 * badge or code span, and an offset exactly at a code span's end lands
831 * AFTER it, so typing at a code span's edge extends the surrounding
832 * text. Interior code-span offsets land in the element's text.
833 * Understands the same block/`<br>` newline shapes as
834 * `serializeContent`.
835 */
836export function textOffsetToRange(root: HTMLElement, offset: number): Range {
837	const range = document.createRange();
838
839	let remaining = offset;
840	let landed = false;
841	let pendingBlockBoundary = false;
842
843	const land = (node: Node, nodeOffset: number) => {
844		range.setStart(node, nodeOffset);
845		range.setEnd(node, nodeOffset);
846		landed = true;
847	};
848	const walk = (parent: Node) => {
849		let first = true;
850
851		for (const child of Array.from(parent.childNodes)) {
852			if (landed) return;
853
854			if (child.nodeType === Node.TEXT_NODE) {
855				const text = child.textContent ?? '';
856
857				if (text.length === 0) continue;
858
859				if (pendingBlockBoundary) {
860					// The synthesized separator maps to the near edge of the
861					// content that follows the block.
862					pendingBlockBoundary = false;
863
864					if (remaining === 0) {
865						land(child, 0);
866
867						return;
868					}
869
870					remaining -= 1;
871				}
872
873				if (remaining <= text.length) {
874					land(child, remaining);
875
876					return;
877				}
878
879				remaining -= text.length;
880				first = false;
881
882				continue;
883			}
884
885			if (child.nodeType !== Node.ELEMENT_NODE) continue;
886
887			const el = child as HTMLElement;
888
889			if (el.getAttribute(MENTION_BADGE_DATA_ATTRS.BADGE) === BooleanString.TRUE) {
890				const len = badgeSourceLength(
891					el.getAttribute(MENTION_BADGE_DATA_ATTRS.NAME) ?? '',
892					el.getAttribute(MENTION_BADGE_DATA_ATTRS.PATH) ?? ''
893				);
894
895				if (len === 0) continue;
896
897				if (pendingBlockBoundary) {
898					pendingBlockBoundary = false;
899
900					if (remaining === 0) {
901						range.setStartBefore(el);
902						range.setEndBefore(el);
903						landed = true;
904
905						return;
906					}
907
908					remaining -= 1;
909				}
910
911				if (remaining <= len) {
912					if (remaining === 0) {
913						range.setStartBefore(el);
914						range.setEndBefore(el);
915					} else {
916						range.setStartAfter(el);
917						range.setEndAfter(el);
918					}
919
920					landed = true;
921
922					return;
923				}
924
925				remaining -= len;
926				first = false;
927
928				continue;
929			}
930
931			const codeToken = el.getAttribute(CODE_TOKEN_ATTR);
932
933			if (codeToken !== null) {
934				const isBlock = codeToken === ChatFormInputRichTokenKind.CODE_BLOCK;
935
936				if (isBlock && (pendingBlockBoundary || !first)) {
937					pendingBlockBoundary = false;
938
939					if (remaining === 0) {
940						range.setStartBefore(el);
941						range.setEndBefore(el);
942						landed = true;
943
944						return;
945					}
946
947					remaining -= 1;
948				}
949
950				const len = (el.textContent ?? '').length;
951
952				if (remaining === 0) {
953					range.setStartBefore(el);
954					range.setEndBefore(el);
955					landed = true;
956
957					return;
958				}
959
960				if (remaining === len) {
961					range.setStartAfter(el);
962					range.setEndAfter(el);
963					landed = true;
964
965					return;
966				}
967
968				if (remaining < len) {
969					walk(el);
970
971					return;
972				}
973
974				remaining -= len;
975
976				if (isBlock) remaining -= 1;
977
978				first = false;
979
980				continue;
981			}
982
983			if (el.tagName === 'BR') {
984				const isHatch =
985					isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
986
987				if (isHatch) {
988					// Escape hatch: no source length; offset 0 lands before it
989					// so text typed there takes its place.
990					if (remaining === 0) {
991						range.setStartBefore(el);
992						range.setEndBefore(el);
993						landed = true;
994					}
995
996					continue;
997				}
998
999				if (!el.nextSibling) continue;
1000
1001				if (pendingBlockBoundary) {
1002					pendingBlockBoundary = false;
1003
1004					if (remaining === 0) {
1005						range.setStartBefore(el);
1006						range.setEndBefore(el);
1007						landed = true;
1008
1009						return;
1010					}
1011
1012					remaining -= 1;
1013				}
1014
1015				if (remaining === 0) {
1016					range.setStartBefore(el);
1017					range.setEndBefore(el);
1018					landed = true;
1019
1020					return;
1021				}
1022
1023				remaining -= 1;
1024				first = false;
1025
1026				continue;
1027			}
1028
1029			if (BLOCK_TAG_NAMES.has(el.tagName)) {
1030				if (pendingBlockBoundary || !first) {
1031					pendingBlockBoundary = false;
1032
1033					if (remaining === 0) {
1034						// The boundary newline belongs to the previous line.
1035						range.setStartBefore(el);
1036						range.setEndBefore(el);
1037						landed = true;
1038
1039						return;
1040					}
1041
1042					remaining -= 1;
1043				}
1044
1045				walk(el);
1046				first = false;
1047
1048				continue;
1049			}
1050
1051			const before = remaining;
1052
1053			walk(el);
1054
1055			if (remaining < before) first = false;
1056		}
1057	};
1058
1059	walk(root);
1060
1061	if (!landed) {
1062		const last = root.lastChild;
1063
1064		if (last && last.nodeName === 'BR') {
1065			range.setStartBefore(last);
1066			range.setEndBefore(last);
1067		} else {
1068			range.selectNodeContents(root);
1069			range.collapse(false);
1070		}
1071	}
1072
1073	return range;
1074}