@runtypelabs/persona 4.8.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Contenteditable adapter — a `ComposerInputCapability` backed by a
3
+ * `contenteditable` surface with atomic inline mention tokens (inline mode,
4
+ * `display: "inline"`). This is the HEAVY, DOM-bearing half of the inline
5
+ * composer; it lives in the separate `context-mentions-inline.js` chunk and is
6
+ * the only place `composer-document.ts` is used at runtime on the widget path.
7
+ *
8
+ * Sync strategy (decided in docs/context-mentions-inline-plan.md — do not
9
+ * re-litigate): the DOM is the editing surface and the document is DERIVED by
10
+ * re-parsing, never the other way around.
11
+ *
12
+ * 1. Plain typing is handled natively inside text nodes — no `beforeinput`
13
+ * interception for text (fighting the caret/undo/IME machinery is where
14
+ * contenteditable projects die).
15
+ * 2. Reads (`getDocument`/`getLogicalText`/`getValue`/`getSelection`) re-parse
16
+ * the DOM live: `data-mention-id` spans → mention blocks (ref looked up from
17
+ * `refs`, never parsed from DOM text); everything else → text. Cheap at
18
+ * composer size, and it removes any chance of stale state after native edits.
19
+ * 3. `beforeinput` is intercepted ONLY to make token deletion atomic when the
20
+ * caret is adjacent to a token; `contenteditable="false"` on the span handles
21
+ * arrow/selection atomicity natively.
22
+ * 4. IME: never mutate between `compositionstart`/`compositionend`.
23
+ * 5. Programmatic edits render the document, restore the caret from a logical
24
+ * offset, then dispatch `input`.
25
+ * 6. Paste inserts `text/plain` only (rich token paste across composers is a
26
+ * non-goal — a pasted `@App.tsx` is just text).
27
+ *
28
+ * Token removal (by backspace, cut, or select-all-delete) is detected uniformly
29
+ * by reconciling `refs` against the DOM on `input` and firing `onMentionRemoved`
30
+ * for each id that vanished — so the manager aborts that mention's resolve.
31
+ */
32
+
33
+ import {
34
+ emptyDocument,
35
+ documentFromTextarea,
36
+ toDisplayText,
37
+ toLogicalText,
38
+ logicalLength,
39
+ insertMention,
40
+ removeMention,
41
+ spliceDocument,
42
+ MENTION_PLACEHOLDER,
43
+ type ComposerDocument,
44
+ type ComposerMentionId
45
+ } from "./composer-document";
46
+ import type { MentionTriggerMatch } from "./mention-trigger";
47
+ import { requestFormSubmit, type ComposerInputCapability } from "./composer-input";
48
+ import type { AgentWidgetContextMentionRef } from "../types";
49
+
50
+ export const MENTION_TOKEN_CLASS = "persona-mention-token";
51
+ const MENTION_TOKEN_ERROR_CLASS = "persona-mention-token-error";
52
+ const MENTION_ID_ATTR = "data-mention-id";
53
+ // Pre-error aria-label/title stash, so error recovery can restore host-rendered
54
+ // tokens (renderMentionToken) exactly as they were (see setMentionStatus).
55
+ const ERROR_PREV_LABEL_ATTR = "data-mention-prev-label";
56
+ const ERROR_PREV_TITLE_ATTR = "data-mention-prev-title";
57
+ const NEWLINE = "\n";
58
+
59
+ export interface ContentEditableComposerInputOptions {
60
+ /** Generate a stable id for a newly inserted mention (manager/uuid-backed). */
61
+ generateId: () => ComposerMentionId;
62
+ /**
63
+ * Build a token's full element. Supplied by the core orchestrator (which owns
64
+ * the shared pill + icon renderer). The adapter marks the returned element
65
+ * `contenteditable="false"` and stamps `data-mention-id`.
66
+ */
67
+ renderToken: (ref: AgentWidgetContextMentionRef) => HTMLElement;
68
+ /** Fired when a token is inserted — the manager starts resolve keyed by id. */
69
+ onMentionInserted?: (
70
+ id: ComposerMentionId,
71
+ ref: AgentWidgetContextMentionRef
72
+ ) => void;
73
+ /** Fired when a token disappears from the DOM — the manager aborts its resolve. */
74
+ onMentionRemoved?: (id: ComposerMentionId) => void;
75
+ /** Placeholder text shown while the surface is empty. */
76
+ placeholder?: string;
77
+ /** Existing element to upgrade in place; otherwise a fresh `<div>` is created. */
78
+ element?: HTMLElement;
79
+ }
80
+
81
+ function isMentionSpan(node: Node): node is HTMLElement {
82
+ return (
83
+ node.nodeType === 1 &&
84
+ (node as HTMLElement).hasAttribute(MENTION_ID_ATTR)
85
+ );
86
+ }
87
+
88
+ /** A `<br>` the browser inserts on Shift+Enter / line-break input. */
89
+ function isLineBreak(node: Node): boolean {
90
+ return node.nodeType === 1 && (node as HTMLElement).tagName === "BR";
91
+ }
92
+
93
+ /**
94
+ * Logical length one DOM child contributes: text nodes count their chars, a
95
+ * mention token is one ``, and a `<br>` is one `\n` (native line breaks read back
96
+ * as newlines). Any other wrapper counts its `textContent`.
97
+ */
98
+ function childLogicalLength(node: Node): number {
99
+ if (node.nodeType === 3) return (node as Text).data.length;
100
+ if (isMentionSpan(node)) return 1;
101
+ if (isLineBreak(node)) return 1;
102
+ return node.textContent?.length ?? 0;
103
+ }
104
+
105
+ /**
106
+ * Map a DOM position (node + offset within it) to a logical-text offset. Exported
107
+ * for unit tests — it's the load-bearing caret math and jsdom's Selection is too
108
+ * thin to exercise it end-to-end.
109
+ */
110
+ export function domPositionToLogical(
111
+ root: HTMLElement,
112
+ node: Node,
113
+ offset: number
114
+ ): number {
115
+ let logical = 0;
116
+ for (const child of Array.from(root.childNodes)) {
117
+ if (child === node) {
118
+ if (child.nodeType === 3) return logical + offset;
119
+ // The caret is on the root's child list at this element: offset 0 = before.
120
+ return logical + (offset > 0 ? childLogicalLength(child) : 0);
121
+ }
122
+ if (child.contains(node)) {
123
+ // Caret nested inside this child. A token is atomic — clamp to its edges;
124
+ // any other wrapper (foreign paste) counts its text up to the target.
125
+ if (isMentionSpan(child)) return logical + 1;
126
+ return logical + nestedTextOffset(child, node, offset);
127
+ }
128
+ logical += childLogicalLength(child);
129
+ }
130
+ // `node` is the root itself: offset counts child slots.
131
+ if (node === root) {
132
+ let logicalAt = 0;
133
+ const kids = Array.from(root.childNodes);
134
+ for (let i = 0; i < Math.min(offset, kids.length); i++) {
135
+ logicalAt += childLogicalLength(kids[i]);
136
+ }
137
+ return logicalAt;
138
+ }
139
+ return logical;
140
+ }
141
+
142
+ /** Text length inside `container` up to (`target`, `offset`). */
143
+ function nestedTextOffset(container: Node, target: Node, offset: number): number {
144
+ let count = 0;
145
+ const walk = (node: Node): boolean => {
146
+ if (node === target) {
147
+ count += node.nodeType === 3 ? offset : 0;
148
+ return true;
149
+ }
150
+ if (node.nodeType === 3) {
151
+ count += (node as Text).data.length;
152
+ return false;
153
+ }
154
+ for (const child of Array.from(node.childNodes)) {
155
+ if (walk(child)) return true;
156
+ }
157
+ return false;
158
+ };
159
+ walk(container);
160
+ return count;
161
+ }
162
+
163
+ /**
164
+ * Map a logical-text offset to a DOM position (node + offset) for caret
165
+ * restoration. Exported for unit tests. Tokens are atomic, so an offset landing
166
+ * on a token resolves to a root-level position immediately before or after it.
167
+ */
168
+ export function logicalToDomPosition(
169
+ root: HTMLElement,
170
+ offset: number
171
+ ): { node: Node; offset: number } {
172
+ let acc = 0;
173
+ const kids = Array.from(root.childNodes);
174
+ for (let i = 0; i < kids.length; i++) {
175
+ const child = kids[i];
176
+ const len = childLogicalLength(child);
177
+ if (offset <= acc + len) {
178
+ if (child.nodeType === 3) return { node: child, offset: offset - acc };
179
+ // Element (token / wrapper): before it when at its start, else after it.
180
+ return { node: root, offset: offset <= acc ? i : i + 1 };
181
+ }
182
+ acc += len;
183
+ }
184
+ return { node: root, offset: kids.length };
185
+ }
186
+
187
+ /**
188
+ * Read the DOM subtree into a `ComposerDocument`. `data-mention-id` spans become
189
+ * mention blocks (ref from `refs`); text nodes become text blocks; any other node
190
+ * contributes its `textContent` as text. Unknown ids are dropped (a token whose
191
+ * ref we lost is treated as gone).
192
+ */
193
+ function parseDomToDocument(
194
+ root: HTMLElement,
195
+ refs: Map<ComposerMentionId, AgentWidgetContextMentionRef>
196
+ ): ComposerDocument {
197
+ const blocks: ComposerDocument["blocks"] = [];
198
+ for (const child of Array.from(root.childNodes)) {
199
+ if (isMentionSpan(child)) {
200
+ const id = child.getAttribute(MENTION_ID_ATTR)!;
201
+ const ref = refs.get(id);
202
+ if (ref) blocks.push({ kind: "mention", id, ref });
203
+ } else if (isLineBreak(child)) {
204
+ // A native line break (Shift+Enter) reads back as a newline so caret math,
205
+ // getValue(), and line-start `/command` detection all stay aligned.
206
+ blocks.push({ kind: "text", value: NEWLINE });
207
+ } else if (child.nodeType === 3) {
208
+ blocks.push({ kind: "text", value: (child as Text).data });
209
+ } else {
210
+ const text = child.textContent ?? "";
211
+ if (text) blocks.push({ kind: "text", value: text });
212
+ }
213
+ }
214
+ if (blocks.length === 0) blocks.push({ kind: "text", value: "" });
215
+ return { blocks };
216
+ }
217
+
218
+ /**
219
+ * Create a contenteditable-backed `ComposerInputCapability`. The returned object
220
+ * additionally carries `destroy()` to detach listeners on unmount.
221
+ */
222
+ export function createContentEditableComposerInput(
223
+ options: ContentEditableComposerInputOptions
224
+ ): ComposerInputCapability & { destroy: () => void } {
225
+ const renderToken = options.renderToken;
226
+ const refs = new Map<ComposerMentionId, AgentWidgetContextMentionRef>();
227
+
228
+ const root = options.element ?? document.createElement("div");
229
+ root.setAttribute("contenteditable", "true");
230
+ root.setAttribute("data-persona-composer-input", "");
231
+ root.setAttribute("role", "textbox");
232
+ root.setAttribute("aria-multiline", "true");
233
+ root.classList.add("persona-composer-contenteditable");
234
+ if (options.placeholder != null) {
235
+ root.setAttribute("data-placeholder", options.placeholder);
236
+ }
237
+
238
+ let isComposing = false;
239
+ // Last logical caret we intended. Mirrors a textarea's `selectionStart` after a
240
+ // `.value =` write: it survives even when the composer is unfocused (so we don't
241
+ // touch the host-page selection) and backs `getSelection()` when no live range
242
+ // sits inside the surface.
243
+ let logicalCaret = 0;
244
+
245
+ const getDocument = (): ComposerDocument => parseDomToDocument(root, refs);
246
+
247
+ /** Re-render the document; empty text blocks are skipped (caret sits at the
248
+ * root child boundary), so an empty document leaves the root `:empty` for the
249
+ * placeholder. Refs are (re)registered as tokens are painted. */
250
+ const render = (doc: ComposerDocument): void => {
251
+ root.replaceChildren();
252
+ for (const block of doc.blocks) {
253
+ if (block.kind === "text") {
254
+ if (block.value.length > 0) {
255
+ root.appendChild(document.createTextNode(block.value));
256
+ }
257
+ } else {
258
+ refs.set(block.id, block.ref);
259
+ // The token element is the atomic node: mark it non-editable and stamp its
260
+ // id (used by re-parse + removal reconcile), regardless of who rendered it.
261
+ const el = renderToken(block.ref);
262
+ el.setAttribute("contenteditable", "false");
263
+ el.setAttribute(MENTION_ID_ATTR, block.id);
264
+ root.appendChild(el);
265
+ }
266
+ }
267
+ };
268
+
269
+ /** Does the composer currently own the document/shadow-root selection focus? */
270
+ const ownsFocus = (): boolean => {
271
+ const activeRoot = root.getRootNode() as Document | ShadowRoot;
272
+ const active = activeRoot.activeElement;
273
+ return active === root || (active != null && root.contains(active));
274
+ };
275
+
276
+ /**
277
+ * Place the collapsed caret at a logical offset. By default this only touches
278
+ * the document selection when the composer OWNS focus — a programmatic write
279
+ * (mount migration, `.value` shim) must never wipe a selection the user is
280
+ * making elsewhere on the host page. Pass `{ focus: true }` for flows that
281
+ * intentionally drive the caret while focused (mention insertion, paste).
282
+ */
283
+ const setCaret = (offset: number, opts?: { focus?: boolean }): void => {
284
+ logicalCaret = offset; // remember intent even when we don't touch the DOM
285
+ if (!opts?.focus && !ownsFocus()) return;
286
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
287
+ if (!sel) return;
288
+ const pos = logicalToDomPosition(root, offset);
289
+ try {
290
+ const range = document.createRange();
291
+ range.setStart(pos.node, pos.offset);
292
+ range.collapse(true);
293
+ sel.removeAllRanges();
294
+ sel.addRange(range);
295
+ } catch {
296
+ /* selection unavailable (SSR/jsdom edge) — ignore */
297
+ }
298
+ };
299
+
300
+ const dispatchInput = (): void => {
301
+ root.dispatchEvent(new Event("input", { bubbles: true }));
302
+ };
303
+
304
+ const getSelection = (): { start: number; end: number } => {
305
+ // No live range inside the surface (unfocused / focus elsewhere): fall back to
306
+ // the last intended caret, mirroring a textarea's persisted `selectionStart`.
307
+ const fallback = { start: logicalCaret, end: logicalCaret };
308
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
309
+ if (!sel || sel.rangeCount === 0) return fallback;
310
+ const range = sel.getRangeAt(0);
311
+ if (!root.contains(range.startContainer)) return fallback;
312
+ const start = domPositionToLogical(
313
+ root,
314
+ range.startContainer,
315
+ range.startOffset
316
+ );
317
+ const end = range.collapsed
318
+ ? start
319
+ : domPositionToLogical(root, range.endContainer, range.endOffset);
320
+ return { start: Math.min(start, end), end: Math.max(start, end) };
321
+ };
322
+
323
+ /** Fire `onMentionRemoved` for any tracked token no longer present in the DOM. */
324
+ const reconcileRemovedMentions = (): void => {
325
+ const present = new Set<string>();
326
+ for (const child of Array.from(root.childNodes)) {
327
+ if (isMentionSpan(child)) present.add(child.getAttribute(MENTION_ID_ATTR)!);
328
+ }
329
+ for (const id of Array.from(refs.keys())) {
330
+ if (!present.has(id)) {
331
+ refs.delete(id);
332
+ options.onMentionRemoved?.(id);
333
+ }
334
+ }
335
+ };
336
+
337
+ const onInput = (): void => {
338
+ if (isComposing) return;
339
+ if (refs.size === 0) return; // no tracked tokens → nothing to reconcile
340
+ reconcileRemovedMentions();
341
+ };
342
+
343
+ const onCompositionStart = (): void => {
344
+ isComposing = true;
345
+ };
346
+ const onCompositionEnd = (): void => {
347
+ isComposing = false;
348
+ reconcileRemovedMentions();
349
+ };
350
+
351
+ const onPaste = (event: ClipboardEvent): void => {
352
+ event.preventDefault();
353
+ const text = event.clipboardData?.getData("text/plain") ?? "";
354
+ if (typeof document.execCommand === "function") {
355
+ document.execCommand("insertText", false, text);
356
+ } else {
357
+ // Fallback: splice at the DOCUMENT-MODEL level so tokens outside the pasted
358
+ // range survive (a plain-text splice would turn every `` into a literal
359
+ // character and orphan its mention). Paste runs focused → keep the caret.
360
+ const { start, end } = getSelection();
361
+ const result = spliceDocument(getDocument(), start, end, text);
362
+ render(result.doc);
363
+ setCaret(result.caret, { focus: true });
364
+ reconcileRemovedMentions();
365
+ }
366
+ dispatchInput();
367
+ };
368
+
369
+ /**
370
+ * Intercept ONLY the deletion cases browsers can get wrong around an atomic
371
+ * token: a collapsed caret adjacent to a token (delete the whole token) — the
372
+ * common `contenteditable="false"` handling already covers most of this, so
373
+ * this is a defensive backstop. Everything else falls through to native editing.
374
+ */
375
+ const onBeforeInput = (event: Event): void => {
376
+ if (isComposing) return;
377
+ const type = (event as InputEvent).inputType;
378
+ // Normalize a paragraph split to a plain newline so the flat document model
379
+ // never has to unwind browser `<div>`/`<p>` wrapping. (A bare line break —
380
+ // Shift+Enter's `insertLineBreak` — inserts a native `<br>`, which the parser
381
+ // already reads back as `\n`, so we leave it to native editing to keep the
382
+ // caret/undo machinery intact.)
383
+ if (type === "insertParagraph") {
384
+ event.preventDefault();
385
+ const { start, end } = getSelection();
386
+ const result = spliceDocument(getDocument(), start, end, NEWLINE);
387
+ render(result.doc);
388
+ setCaret(result.caret, { focus: true });
389
+ if (refs.size > 0) reconcileRemovedMentions();
390
+ dispatchInput();
391
+ return;
392
+ }
393
+ if (type !== "deleteContentBackward" && type !== "deleteContentForward") {
394
+ return;
395
+ }
396
+ if (refs.size === 0) return; // no tokens to protect → let native delete run
397
+ const { start, end } = getSelection();
398
+ if (start !== end) return; // range deletes: let native handle, reconcile after
399
+ const doc = getDocument();
400
+ const logical = toLogicalText(doc);
401
+ const probe = type === "deleteContentBackward" ? start - 1 : start;
402
+ if (probe < 0 || probe >= logical.length) return;
403
+ if (logical[probe] !== MENTION_PLACEHOLDER) return; // not adjacent to a token
404
+ // Find which mention sits at `probe` and remove it atomically.
405
+ let offset = 0;
406
+ let targetId: ComposerMentionId | null = null;
407
+ for (const block of doc.blocks) {
408
+ const len = block.kind === "text" ? block.value.length : 1;
409
+ if (block.kind === "mention" && probe >= offset && probe < offset + len) {
410
+ targetId = block.id;
411
+ break;
412
+ }
413
+ offset += len;
414
+ }
415
+ if (!targetId) return;
416
+ event.preventDefault();
417
+ const result = removeMention(doc, targetId);
418
+ render(result.doc);
419
+ setCaret(result.caret, { focus: true });
420
+ reconcileRemovedMentions();
421
+ dispatchInput();
422
+ };
423
+
424
+ const setValueWithCaret = (value: string, caret: number): void => {
425
+ render(documentFromTextarea(value));
426
+ setCaret(caret);
427
+ };
428
+
429
+ /**
430
+ * Viewport-coordinate rect of the LOGICAL range `[start, end)`. Builds a
431
+ * NON-collapsed DOM Range around a real glyph (the whole trick — collapsed
432
+ * ranges at a node boundary measure empty in most engines) and returns its
433
+ * bounding rect. Returns `null` for an empty/invalid range or a degenerate
434
+ * (zero-size) rect so callers fall back to composer anchoring. Cheap: one Range
435
+ * + one `getBoundingClientRect`, only invoked on measure (not per keystroke).
436
+ */
437
+ const getLogicalRangeRect = (
438
+ start: number,
439
+ end: number
440
+ ): DOMRect | null => {
441
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
442
+ return null;
443
+ }
444
+ try {
445
+ const s = logicalToDomPosition(root, start);
446
+ const e = logicalToDomPosition(root, end);
447
+ const range = document.createRange();
448
+ range.setStart(s.node, s.offset);
449
+ range.setEnd(e.node, e.offset);
450
+ const rect = range.getBoundingClientRect();
451
+ if (rect.width === 0 && rect.height === 0) return null;
452
+ return rect;
453
+ } catch {
454
+ return null;
455
+ }
456
+ };
457
+
458
+ /**
459
+ * Token-preserving logical-range replace (the controller's query-strip /
460
+ * slash-completion / prompt-macro rewrites). Splices at the DOCUMENT level so
461
+ * every token outside `[start, end)` survives; the dispatched `input` reconciles
462
+ * any token the range covered. Does not itself fire `input` — the caller does,
463
+ * matching `setValueWithCaret`.
464
+ */
465
+ const replaceLogicalRange = (
466
+ start: number,
467
+ end: number,
468
+ text: string
469
+ ): void => {
470
+ const result = spliceDocument(getDocument(), start, end, text);
471
+ render(result.doc);
472
+ setCaret(result.caret, { focus: true });
473
+ };
474
+
475
+ const insertMentionAtTrigger = (
476
+ ref: AgentWidgetContextMentionRef,
477
+ match: MentionTriggerMatch
478
+ ): ComposerMentionId | null => {
479
+ const doc = getDocument();
480
+ const logical = toLogicalText(doc);
481
+ const start = match.triggerIndex;
482
+ const end = match.triggerIndex + 1 + match.query.length;
483
+ // Staleness guard: the typed query region must still be intact.
484
+ if (
485
+ start < 0 ||
486
+ end > logical.length ||
487
+ logical.slice(start + 1, end) !== match.query
488
+ ) {
489
+ return null;
490
+ }
491
+ const id = options.generateId();
492
+ const result = insertMention(doc, { start, end }, ref, id);
493
+ render(result.doc);
494
+ setCaret(result.caret, { focus: true });
495
+ options.onMentionInserted?.(id, ref);
496
+ dispatchInput();
497
+ return id;
498
+ };
499
+
500
+ const insertMentionAtSelection = (
501
+ ref: AgentWidgetContextMentionRef
502
+ ): ComposerMentionId => {
503
+ const doc = getDocument();
504
+ const sel = getSelection();
505
+ // Picker path: no live caret in the composer (focus was in the menu search) →
506
+ // append at the end of the document.
507
+ const hasCaret =
508
+ typeof window !== "undefined" &&
509
+ !!window.getSelection()?.rangeCount &&
510
+ root.contains(window.getSelection()!.getRangeAt(0).startContainer);
511
+ const range = hasCaret
512
+ ? { start: sel.start, end: sel.end }
513
+ : { start: logicalLength(doc), end: logicalLength(doc) };
514
+ const id = options.generateId();
515
+ const result = insertMention(doc, range, ref, id);
516
+ render(result.doc);
517
+ setCaret(result.caret, { focus: true });
518
+ options.onMentionInserted?.(id, ref);
519
+ dispatchInput();
520
+ return id;
521
+ };
522
+
523
+ /** Toggle the inline error affordance on the token element for `id` (no-op for
524
+ * unknown ids); any non-error status clears it. On error, also surface the
525
+ * failure to assistive tech via both the `title` tooltip AND the token's
526
+ * `aria-label` (the red styling alone is visual-only, so a screen-reader user
527
+ * would otherwise never learn the context failed to attach). The token's own
528
+ * name/tooltip are stashed before the error override and restored verbatim on
529
+ * recovery — tokens can come from the host's `renderMentionToken` hook, which
530
+ * carries none of the default builder's markup to re-derive them from, so
531
+ * non-error statuses must never rewrite attributes on a token that was never
532
+ * in the error state. */
533
+ const setMentionStatus = (
534
+ id: ComposerMentionId,
535
+ status: "pending" | "resolved" | "error"
536
+ ): void => {
537
+ for (const child of Array.from(root.childNodes)) {
538
+ if (isMentionSpan(child) && child.getAttribute(MENTION_ID_ATTR) === id) {
539
+ const isError = status === "error";
540
+ child.classList.toggle(MENTION_TOKEN_ERROR_CLASS, isError);
541
+ if (isError) {
542
+ if (!child.hasAttribute(ERROR_PREV_LABEL_ATTR)) {
543
+ child.setAttribute(
544
+ ERROR_PREV_LABEL_ATTR,
545
+ child.getAttribute("aria-label") ?? ""
546
+ );
547
+ child.setAttribute(
548
+ ERROR_PREV_TITLE_ATTR,
549
+ child.getAttribute("title") ?? ""
550
+ );
551
+ }
552
+ const label = refs.get(id)?.label ?? "";
553
+ child.setAttribute("title", `${label}: failed to attach context`);
554
+ child.setAttribute("aria-label", `${label}, failed to attach context`);
555
+ } else if (child.hasAttribute(ERROR_PREV_LABEL_ATTR)) {
556
+ // A stashed empty string means the attribute was absent pre-error.
557
+ const prevLabel = child.getAttribute(ERROR_PREV_LABEL_ATTR);
558
+ const prevTitle = child.getAttribute(ERROR_PREV_TITLE_ATTR);
559
+ if (prevLabel) child.setAttribute("aria-label", prevLabel);
560
+ else child.removeAttribute("aria-label");
561
+ if (prevTitle) child.setAttribute("title", prevTitle);
562
+ else child.removeAttribute("title");
563
+ child.removeAttribute(ERROR_PREV_LABEL_ATTR);
564
+ child.removeAttribute(ERROR_PREV_TITLE_ATTR);
565
+ }
566
+ return;
567
+ }
568
+ }
569
+ };
570
+
571
+ root.addEventListener("input", onInput);
572
+ root.addEventListener("compositionstart", onCompositionStart);
573
+ root.addEventListener("compositionend", onCompositionEnd);
574
+ root.addEventListener("paste", onPaste);
575
+ root.addEventListener("beforeinput", onBeforeInput);
576
+
577
+ // Start empty (placeholder visible).
578
+ render(emptyDocument());
579
+
580
+ return {
581
+ element: root,
582
+ getValue: () => toDisplayText(getDocument()),
583
+ getLogicalText: () => toLogicalText(getDocument()),
584
+ getDocument,
585
+ getSelection,
586
+ setSelection: (start, end = start) => {
587
+ const sel = typeof window !== "undefined" ? window.getSelection() : null;
588
+ if (!sel) return;
589
+ try {
590
+ const s = logicalToDomPosition(root, start);
591
+ const e = logicalToDomPosition(root, end);
592
+ const range = document.createRange();
593
+ range.setStart(s.node, s.offset);
594
+ range.setEnd(e.node, e.offset);
595
+ sel.removeAllRanges();
596
+ sel.addRange(range);
597
+ } catch {
598
+ /* ignore */
599
+ }
600
+ },
601
+ setValueWithCaret,
602
+ getLogicalRangeRect,
603
+ setValue: (value: string) => {
604
+ // The `.value` shim setter: focus FIRST so the subsequent caret placement
605
+ // owns the selection (setCaret is a no-op when unfocused, by design).
606
+ render(documentFromTextarea(value));
607
+ root.focus();
608
+ setCaret(value.length, { focus: true });
609
+ dispatchInput();
610
+ },
611
+ replaceLogicalRange,
612
+ insertMentionAtTrigger,
613
+ insertMentionAtSelection,
614
+ setMentionStatus,
615
+ submit: () => requestFormSubmit(root.closest("form")),
616
+ dispatchInput,
617
+ focus: () => root.focus(),
618
+ destroy: () => {
619
+ root.removeEventListener("input", onInput);
620
+ root.removeEventListener("compositionstart", onCompositionStart);
621
+ root.removeEventListener("compositionend", onCompositionEnd);
622
+ root.removeEventListener("paste", onPaste);
623
+ root.removeEventListener("beforeinput", onBeforeInput);
624
+ }
625
+ };
626
+ }