@runtypelabs/persona 4.7.0 → 4.9.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 (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,1186 @@
1
+ import { createPopover, injectStyles, type PopoverHandle } from "../plugin-kit";
2
+ import { MENTION_MENU_CSS } from "../styles/context-mention-menu-css";
3
+ import {
4
+ parseAnyTrigger,
5
+ stripMentionQuery,
6
+ type MentionTriggerMatch,
7
+ } from "./mention-trigger";
8
+ import {
9
+ normalizeMentionChannels,
10
+ type NormalizedMentionChannel,
11
+ } from "./mention-channels";
12
+ import { splitCommandQuery } from "./mention-matcher";
13
+ import {
14
+ createMentionMenu,
15
+ type MentionMenuGroup,
16
+ type MentionMenuParts,
17
+ } from "../components/context-mention-menu";
18
+ import { refFromItem, type MentionSubmitBundle } from "./context-mention-manager";
19
+ import type { ComposerInputCapability } from "./composer-input";
20
+ import type { ComposerMentionId } from "./composer-document";
21
+ import type {
22
+ AgentWidgetConfig,
23
+ AgentWidgetContextMentionConfig,
24
+ AgentWidgetContextMentionItem,
25
+ AgentWidgetContextMentionRef,
26
+ AgentWidgetContextMentionSource,
27
+ AgentWidgetMessage,
28
+ } from "../types";
29
+
30
+ /**
31
+ * Outcome of dispatching an inline slash command at submit (Slack-style). The UI
32
+ * acts on the kind: an `"action"` already ran and nothing is sent; a `"prompt"`
33
+ * replaces the outgoing text with `sendText`; a `"server"` sends the typed text
34
+ * and attaches `mentions` context (refs empty — no chip).
35
+ */
36
+ export type InlineCommandResult =
37
+ | { kind: "action" }
38
+ | { kind: "prompt"; sendText: string }
39
+ | {
40
+ kind: "server";
41
+ mentions: {
42
+ refs: AgentWidgetContextMentionRef[];
43
+ finalize: () => Promise<MentionSubmitBundle>;
44
+ };
45
+ };
46
+
47
+ export interface ContextMentionControllerOptions {
48
+ mentionConfig: AgentWidgetContextMentionConfig;
49
+ /**
50
+ * The composer input surface (chip: textarea adapter; inline: contenteditable
51
+ * adapter). The controller drives all text/selection/document ops through this
52
+ * one capability — it also serves as the `command:"prompt"`/`"action"` composer
53
+ * (the superset includes `getValue`/`setValue`/`submit`).
54
+ */
55
+ composerInput: ComposerInputCapability;
56
+ /** Popover anchor — the composer form/pill; the menu opens upward, full-width. */
57
+ anchor: HTMLElement;
58
+ getMessages: () => AgentWidgetMessage[];
59
+ getConfig: () => AgentWidgetConfig;
60
+ /**
61
+ * Commit a MENTION or SERVER-command selection (delegates to the manager);
62
+ * returns false on duplicate/limit. `args` is the text after a command name
63
+ * (empty for ordinary mentions). Prompt/action commands never reach here —
64
+ * the controller dispatches them directly.
65
+ */
66
+ onSelect: (
67
+ source: AgentWidgetContextMentionSource,
68
+ item: AgentWidgetContextMentionItem,
69
+ args: string
70
+ ) => boolean;
71
+ /**
72
+ * INLINE display only. Track a mention whose atomic token was just inserted
73
+ * into the composer, keyed by the composer-generated `ComposerMentionId`.
74
+ * Chip mode leaves this undefined and commits through `onSelect` (chip row).
75
+ */
76
+ onInsertMention?: (
77
+ id: ComposerMentionId,
78
+ source: AgentWidgetContextMentionSource,
79
+ item: AgentWidgetContextMentionItem,
80
+ args: string
81
+ ) => void;
82
+ /**
83
+ * INLINE display only. Admission gate run BEFORE a token is inserted: returns
84
+ * false (and fires the manager's rejection hooks) when the pick hits the mention
85
+ * limit, so a rejected pick never leaves a stray token. Owns the same
86
+ * limit/rejection policy as chip mode's `onSelect` path.
87
+ */
88
+ admitMention?: (
89
+ source: AgentWidgetContextMentionSource,
90
+ item: AgentWidgetContextMentionItem
91
+ ) => boolean;
92
+ announce: (message: string) => void;
93
+ popoverContainer?: HTMLElement | ShadowRoot;
94
+ /**
95
+ * Fired when the affordance-button PICKER opens/closes, so the orchestrator can
96
+ * reflect `aria-expanded`/`aria-controls` on the matching channel button.
97
+ * `trigger` identifies the channel; `listboxId` is the menu the button controls.
98
+ * Only the button-opened picker path fires this — the typed-trigger menu is
99
+ * driven from the composer and never touches a button's state.
100
+ */
101
+ onPickerOpenChange?: (open: boolean, trigger: string, listboxId: string) => void;
102
+ emit?: (event: string, detail: unknown) => void;
103
+ }
104
+
105
+ const isThenable = (v: unknown): v is Promise<unknown> =>
106
+ !!v && (typeof v === "object" || typeof v === "function") &&
107
+ typeof (v as { then?: unknown }).then === "function";
108
+
109
+ let listboxSeq = 0;
110
+
111
+ /**
112
+ * Owns the mention menu lifecycle: `@`-trigger detection, instant open, async-
113
+ * only debounced search with abort, per-group caps, keyboard navigation, and
114
+ * composer-anchored upward positioning. State only — chip/resolve lifecycle is
115
+ * the manager's job (reached via `onSelect`).
116
+ */
117
+ export class ContextMentionController {
118
+ private readonly opts: ContextMentionControllerOptions;
119
+ /** Primary `@` channel + any extra `triggers` channels; one drives the menu. */
120
+ private readonly channels: NormalizedMentionChannel[];
121
+ /** The channel whose trigger is currently open. */
122
+ private activeChannel: NormalizedMentionChannel;
123
+ private readonly maxPerGroup: number;
124
+ private readonly debounceMs: number;
125
+ private readonly menu: MentionMenuParts;
126
+ private readonly listboxId: string;
127
+
128
+ private popover: PopoverHandle | null = null;
129
+ // Watches the composer box while the menu is open: an auto-grow line-wrap moves
130
+ // the `@` glyph (x changes) and shifts the composer's edges (the upward menu's
131
+ // top changes), so on each resize we re-measure the trigger anchor and
132
+ // reposition. Connected on open, disconnected on close/destroy (never leaks).
133
+ private resizeObserver: ResizeObserver | null = null;
134
+ private isOpenState = false;
135
+ private query = "";
136
+ private triggerMatch: MentionTriggerMatch | null = null;
137
+ private activeIndex = 0;
138
+ /** Identity (`sourceId, itemId`) of the highlighted row, so the highlight
139
+ * follows the same item when async results reorder the flat list. */
140
+ private activeKey: string | null = null;
141
+ // True while the menu was opened from the affordance button as a picker: no
142
+ // trigger char is in the textarea, so the in-menu search field owns the query
143
+ // and there is nothing to strip on close.
144
+ private pickerMode = false;
145
+ // The trigger of the channel whose button opened the current picker, so the
146
+ // close path can address the same button's aria-expanded even though `close()`
147
+ // has no channel in hand.
148
+ private pickerTrigger: string | null = null;
149
+
150
+ private groups: MentionMenuGroup[] = [];
151
+ private flat: {
152
+ source: AgentWidgetContextMentionSource;
153
+ item: AgentWidgetContextMentionItem;
154
+ }[] = [];
155
+
156
+ private searchToken = 0;
157
+ private searchAbort: AbortController | null = null;
158
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
159
+ private readonly knownAsync = new Set<string>();
160
+ // Source ids invoked in setQuery's immediate pass for the CURRENT token, so the
161
+ // trailing debounce doesn't re-fire a first-time async source's identical fetch.
162
+ private readonly invokedForToken = new Set<string>();
163
+ private lastAnnouncedCount = -1;
164
+
165
+ // Trigger-anchored menu positioning (inline mode). `triggerAnchorOffset` holds
166
+ // the trigger glyph's x/y deltas from the composer's left/top edges, measured
167
+ // ONCE per trigger session (see `updateTriggerAnchor`). Deltas survive scroll/pan;
168
+ // the popover adds them to the live anchor rect each reposition instead of
169
+ // re-measuring per keystroke. `x` is null in RTL (horizontal falls back).
170
+ private triggerAnchorOffset: { x: number | null; y: number } | null = null;
171
+ private measuredTriggerIndex: number | null = null;
172
+
173
+ constructor(opts: ContextMentionControllerOptions) {
174
+ this.opts = opts;
175
+ const cfg = opts.mentionConfig;
176
+ // One shared normalizer (primary `@` channel + any extra `triggers`); the
177
+ // engine drives them all from one menu. Drop empty channels (e.g. the default
178
+ // `@` channel when only `/` has sources); fall back to the primary if the
179
+ // config somehow declared none (the orchestrator gates this case upstream).
180
+ const all = normalizeMentionChannels(cfg);
181
+ const nonEmpty = all.filter((c) => c.sources.length > 0);
182
+ this.channels = nonEmpty.length > 0 ? nonEmpty : [all[0]];
183
+ this.activeChannel = this.channels[0];
184
+ this.maxPerGroup = cfg.maxItemsPerGroup ?? 6;
185
+ this.debounceMs = cfg.searchDebounceMs ?? 150;
186
+ this.listboxId = `persona-mention-listbox-${++listboxSeq}`;
187
+
188
+ this.menu = opts.mentionConfig.renderMentionMenu
189
+ ? this.createHostMenu()
190
+ : createMentionMenu({
191
+ config: opts.mentionConfig,
192
+ listboxId: this.listboxId,
193
+ onSelectIndex: (i) => this.selectIndex(i),
194
+ // Hover highlights without scrolling (keyboard scrolls; a moving cursor
195
+ // shouldn't yank the list).
196
+ onHoverIndex: (i) => this.setActiveIndex(i, false),
197
+ onRetry: (sourceId) => this.retrySource(sourceId),
198
+ // Picker mode: the in-menu search field is the query source and drives
199
+ // keyboard nav, since focus lives in it (not the textarea).
200
+ onSearchInput: (value) => this.setQuery(value),
201
+ onSearchKeydown: (event) => {
202
+ this.handleKeydown(event);
203
+ },
204
+ });
205
+
206
+ const el = opts.composerInput.element;
207
+ el.setAttribute("aria-haspopup", "listbox");
208
+ el.setAttribute("aria-controls", this.listboxId);
209
+ // NB: no aria-expanded here. The editable surface keeps role="textbox" +
210
+ // aria-multiline (so it retains multiline editing semantics), and
211
+ // aria-expanded is not a supported state on role="textbox" — promoting it to
212
+ // role="combobox" to legitimize aria-expanded would DROP those multiline
213
+ // semantics. The open/updating menu is conveyed instead via aria-haspopup +
214
+ // aria-activedescendant + the result-count live region.
215
+ }
216
+
217
+ /** The composer input surface — all text/selection ops route through this. */
218
+ private get input(): ComposerInputCapability {
219
+ return this.opts.composerInput;
220
+ }
221
+
222
+ // The host-render path wraps `renderMentionMenu` output in a positioned shell;
223
+ // positioning, trigger detection, search, and keyboard stay ours. The host
224
+ // owns its own highlight, so `setActiveIndex` just re-renders.
225
+ private createHostMenu(): MentionMenuParts {
226
+ const el = document.createElement("div");
227
+ el.setAttribute("data-persona-mention-menu", "");
228
+ el.setAttribute("role", "listbox");
229
+ el.id = this.listboxId;
230
+ const paint = () => {
231
+ const custom = this.opts.mentionConfig.renderMentionMenu!({
232
+ query: this.query,
233
+ groups: this.groups.map((g) => ({ source: g.source, items: g.items })),
234
+ status: Object.fromEntries(this.groups.map((g) => [g.source.id, g.status])),
235
+ activeIndex: this.activeIndex,
236
+ select: (item) => {
237
+ const group = this.groups.find((g) => g.items.includes(item));
238
+ if (group) this.commit(group.source, item);
239
+ },
240
+ close: () => this.close(),
241
+ });
242
+ el.replaceChildren(custom);
243
+ };
244
+ return {
245
+ el,
246
+ render: paint,
247
+ setActiveIndex: paint,
248
+ destroy: () => el.remove(),
249
+ };
250
+ }
251
+
252
+ isOpen(): boolean {
253
+ return this.isOpenState;
254
+ }
255
+
256
+ /**
257
+ * Open from the affordance button as a picker (Cursor/Copilot style): open the
258
+ * menu WITHOUT inserting a trigger char into the textarea, and reveal + focus
259
+ * an in-menu search field that owns the query. Because no char is inserted,
260
+ * `triggerMatch` stays null, so `stripQuery()` is a no-op and dismissing the
261
+ * menu leaves the composer text untouched — no stray `@` left behind.
262
+ *
263
+ * When the menu is host-rendered (`renderMentionMenu`), there is no built-in
264
+ * search field; the picker opens in browse-and-click mode with keyboard nav
265
+ * driven from the textarea, and the host owns any filtering UI.
266
+ */
267
+ openFromButton(trigger?: string): void {
268
+ const channel =
269
+ (trigger && this.channels.find((c) => c.trigger === trigger)) ||
270
+ this.channels[0];
271
+
272
+ // If a live trigger token for THIS channel already sits at the caret (the
273
+ // user typed `@que` then clicked the button), adopt it as a normal typed
274
+ // trigger so selection strips the query — rather than opening a picker that
275
+ // would leave the `@que` text stranded in the composer.
276
+ const caret = this.input.getSelection().start;
277
+ const typed = parseAnyTrigger(this.input.getLogicalText(), caret, [channel]);
278
+ if (typed) {
279
+ this.pickerMode = false;
280
+ this.triggerMatch = typed.match;
281
+ if (!this.isOpenState) {
282
+ this.open(typed.match.query, channel);
283
+ } else {
284
+ this.switchChannel(channel);
285
+ this.setQuery(typed.match.query);
286
+ }
287
+ this.input.focus();
288
+ return;
289
+ }
290
+
291
+ // Switching to a different channel's picker while one is already open must
292
+ // close the previous button's state first; close() later emits false for the
293
+ // NEW trigger only, so the old button's aria-expanded would stick true.
294
+ const prevPickerTrigger = this.pickerTrigger;
295
+ this.pickerMode = true;
296
+ this.pickerTrigger = channel.trigger;
297
+ this.triggerMatch = null;
298
+ if (!this.isOpenState) {
299
+ this.open("", channel);
300
+ } else {
301
+ if (prevPickerTrigger && prevPickerTrigger !== channel.trigger) {
302
+ this.opts.onPickerOpenChange?.(false, prevPickerTrigger, this.listboxId);
303
+ }
304
+ this.switchChannel(channel);
305
+ this.setQuery("");
306
+ }
307
+ if (this.menu.showSearch) this.menu.showSearch("", channel.searchPlaceholder);
308
+ else this.input.focus();
309
+ this.opts.onPickerOpenChange?.(true, channel.trigger, this.listboxId);
310
+ }
311
+
312
+ /** Re-parse the composer on every input and open/update/close the menu. */
313
+ onInput(): void {
314
+ const caret = this.input.getSelection().start;
315
+ const hit = parseAnyTrigger(this.input.getLogicalText(), caret, this.channels);
316
+ if (!hit) {
317
+ if (this.isOpenState) this.close();
318
+ return;
319
+ }
320
+ if (this.isInlineArgTail(hit.channel, hit.match.query)) {
321
+ // The command name is complete and the caret is into the argument — the
322
+ // menu has nothing left to offer, so keep it closed (Slack-style). The
323
+ // command runs at submit via `dispatchInlineCommand`.
324
+ this.triggerMatch = null;
325
+ if (this.isOpenState) this.close(false);
326
+ return;
327
+ }
328
+ this.triggerMatch = hit.match;
329
+ if (!this.isOpenState) {
330
+ this.open(hit.match.query, hit.channel);
331
+ } else {
332
+ this.switchChannel(hit.channel);
333
+ // Re-anchor only when the trigger MOVED (new session / edit before the `@`).
334
+ // Plain query typing keeps the same `triggerIndex`, so the menu stays put —
335
+ // no per-keystroke layout work (Slack behavior).
336
+ if (this.measuredTriggerIndex !== hit.match.triggerIndex) {
337
+ this.updateTriggerAnchor();
338
+ }
339
+ this.setQuery(hit.match.query);
340
+ }
341
+ }
342
+
343
+ /** Switch the active channel while open, resetting stale groups from the old one. */
344
+ private switchChannel(channel: NormalizedMentionChannel): void {
345
+ if (channel === this.activeChannel) return;
346
+ this.activeChannel = channel;
347
+ this.groups = [];
348
+ this.activeIndex = 0;
349
+ }
350
+
351
+ private open(query: string, channel: NormalizedMentionChannel): void {
352
+ this.isOpenState = true;
353
+ this.activeChannel = channel;
354
+ this.groups = [];
355
+ this.activeIndex = 0;
356
+ this.lastAnnouncedCount = -1;
357
+ if (!this.popover) {
358
+ // Inline mode with a measurable composer trigger-anchors the menu to the `@`
359
+ // glyph (Slack-style): the menu is content-sized and shifts horizontally.
360
+ // Every other mode keeps the composer-anchored, full-width menu unchanged.
361
+ const anchored = this.canAnchorMenu();
362
+ this.popover = createPopover({
363
+ anchor: this.opts.anchor,
364
+ content: this.menu.el,
365
+ placement: "top-start",
366
+ // Full-width menu only in composer-anchored mode; the trigger-anchored
367
+ // menu must be content-sized so it has room to shift within the composer.
368
+ matchAnchorWidth: !anchored,
369
+ offset: 6,
370
+ container: this.opts.popoverContainer,
371
+ // Trigger-anchored offsets (px from the composer's left/top edges), or
372
+ // null → composer-anchored fallback on that axis. Omitted when not anchored.
373
+ horizontalOffset: anchored
374
+ ? () => this.triggerAnchorOffset?.x ?? null
375
+ : undefined,
376
+ verticalOffset: anchored
377
+ ? () => this.triggerAnchorOffset?.y ?? null
378
+ : undefined,
379
+ // Outside-click / anchor-removed: run the SAME teardown as an explicit
380
+ // close so the debounce timer, in-flight search, and search token are
381
+ // cleaned up too. `popover.close()` inside is a no-op here (the popover
382
+ // already closed itself before firing this), so there's no recursion.
383
+ // Don't steal focus back to the composer on an outside click.
384
+ onDismiss: () => this.close(false),
385
+ });
386
+ // Content-sized menu (popover caps max-width to the composer); a floor keeps
387
+ // short result lists from collapsing. Set inline so no menu-CSS rule is needed.
388
+ // Computed in px against the composer: a `min(220px, 100%)` percentage would
389
+ // resolve against the viewport (the popover is fixed-positioned) and, as
390
+ // min-width beats max-width, overflow composers narrower than 220px.
391
+ if (anchored) {
392
+ const anchorWidth = this.opts.anchor.getBoundingClientRect().width;
393
+ this.menu.el.style.minWidth = `${Math.min(220, anchorWidth)}px`;
394
+ }
395
+ }
396
+ // Measure the trigger glyph ONCE for this session (before the first reposition
397
+ // in setQuery). Re-measures only on a trigger-index change (new session); the
398
+ // popover's own scroll/resize reposition reuses the cached anchor-relative
399
+ // delta, so panning/scrolling stays aligned without re-measuring.
400
+ this.updateTriggerAnchor();
401
+ this.popover.open();
402
+ // Inject the menu CSS into whatever root the menu now lives in (document
403
+ // head by default; the shadow root under `useShadowDom`). Idempotent per
404
+ // root — this chunk carries the menu styles instead of the eager widget.css.
405
+ injectStyles(this.menu.el, "persona-mention-menu", MENTION_MENU_CSS);
406
+ this.observeComposerResize();
407
+ this.opts.emit?.("opened", { trigger: channel.trigger });
408
+ this.setQuery(query);
409
+ }
410
+
411
+ /**
412
+ * While the menu is open, follow the composer as it auto-grows on line-wrap.
413
+ * A wrap moves the `@` glyph to a new line (x shifts) and changes the
414
+ * composer's box (the upward menu's top shifts), so on each resize we re-measure
415
+ * the trigger anchor and reposition. ResizeObserver fires on wrap boundaries,
416
+ * not per character, so this keeps the once-per-session measurement discipline.
417
+ * Repositioning the fixed-position menu never touches the composer's box, so
418
+ * there is no observer feedback loop. Degrades silently where ResizeObserver is
419
+ * unavailable (e.g. jsdom), so behavior falls back to scroll/window-resize only.
420
+ */
421
+ private observeComposerResize(): void {
422
+ if (typeof ResizeObserver === "undefined" || this.resizeObserver) return;
423
+ this.resizeObserver = new ResizeObserver(() => {
424
+ if (!this.isOpenState) return;
425
+ this.updateTriggerAnchor();
426
+ this.popover?.reposition();
427
+ });
428
+ this.resizeObserver.observe(this.opts.anchor);
429
+ }
430
+
431
+ private disconnectComposerResize(): void {
432
+ this.resizeObserver?.disconnect();
433
+ this.resizeObserver = null;
434
+ }
435
+
436
+ /**
437
+ * True when the menu can trigger-anchor to the `@` glyph: inline display with a
438
+ * composer that can measure a logical range rect. Chip/textarea mode (no
439
+ * `getLogicalRangeRect`) keeps the composer-anchored menu — graceful degradation.
440
+ */
441
+ private canAnchorMenu(): boolean {
442
+ return this.isInlineDisplay() && !!this.input.getLogicalRangeRect;
443
+ }
444
+
445
+ /**
446
+ * Measure the `@` trigger glyph and cache its x/y offsets from the composer's
447
+ * left/top edges (deltas, so they survive scroll — the popover adds them to the
448
+ * live anchor rect each reposition). Called once per trigger session and again
449
+ * only when the trigger index changes (new session) — never per keystroke, so
450
+ * plain query typing does no layout work. Caches `null` (composer-anchored
451
+ * fallback on both axes) when the composer can't anchor, there is no live
452
+ * trigger, or the rect is unmeasurable. In RTL, horizontal falls back (`x`:
453
+ * null) but vertical line anchoring still applies. The `@` is one glyph at
454
+ * `triggerIndex`; a non-collapsed range around it measures reliably (a collapsed
455
+ * boundary range measures empty).
456
+ */
457
+ private updateTriggerAnchor(): void {
458
+ const match = this.triggerMatch;
459
+ const measure = this.input.getLogicalRangeRect;
460
+ this.measuredTriggerIndex = match?.triggerIndex ?? null;
461
+ this.triggerAnchorOffset = null;
462
+ if (!match || !measure) return;
463
+ const rect = measure(match.triggerIndex, match.triggerIndex + 1);
464
+ if (!rect) return;
465
+ const anchorRect = this.opts.anchor.getBoundingClientRect();
466
+ const y = rect.top - anchorRect.top;
467
+ const el = this.input.element;
468
+ const rtl =
469
+ typeof getComputedStyle === "function" &&
470
+ getComputedStyle(el).direction === "rtl";
471
+ // RTL: horizontal trigger anchoring is left-to-right math; fall back on x only.
472
+ this.triggerAnchorOffset = {
473
+ x: rtl ? null : rect.left - anchorRect.left,
474
+ y,
475
+ };
476
+ }
477
+
478
+ close(refocus = true): void {
479
+ if (!this.isOpenState) return;
480
+ this.isOpenState = false;
481
+ this.disconnectComposerResize();
482
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
483
+ this.searchAbort?.abort();
484
+ // Invalidate any in-flight async search so late results can't render,
485
+ // announce, or emit into a closed menu.
486
+ this.searchToken++;
487
+ this.popover?.close();
488
+ this.input.element.removeAttribute("aria-activedescendant");
489
+ if (this.pickerMode) {
490
+ // Picker teardown: hide the search field and (unless dismissed by an
491
+ // outside click) hand focus back to the composer so the user keeps typing.
492
+ this.pickerMode = false;
493
+ this.menu.hideSearch?.();
494
+ this.opts.onPickerOpenChange?.(
495
+ false,
496
+ this.pickerTrigger ?? this.activeChannel.trigger,
497
+ this.listboxId
498
+ );
499
+ this.pickerTrigger = null;
500
+ if (refocus) this.input.focus();
501
+ }
502
+ }
503
+
504
+ private setQuery(query: string): void {
505
+ this.query = query;
506
+ // A new query resets the highlight to the first result (standard
507
+ // autocomplete). Async results for the SAME query keep it via `activeKey`.
508
+ this.activeIndex = 0;
509
+ this.activeKey = null;
510
+ const token = ++this.searchToken;
511
+ this.invokedForToken.clear();
512
+ this.searchAbort?.abort();
513
+ this.searchAbort = new AbortController();
514
+
515
+ // Immediate pass: sync sources (and first-time sources) render with zero
516
+ // debounce; sources already known to be async show a loading shimmer.
517
+ for (const source of this.activeChannel.sources) {
518
+ if (this.knownAsync.has(source.id)) {
519
+ this.setGroupStatus(source.id, "loading");
520
+ } else {
521
+ this.invokedForToken.add(source.id);
522
+ this.invokeSource(source, token);
523
+ }
524
+ }
525
+ this.render();
526
+
527
+ // Debounced pass: re-invoke the known-async (network) sources, skipping any a
528
+ // first-time invoke already fired for this token (invokeSource marks a source
529
+ // async on its first promise — re-invoking here would double-fetch it).
530
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
531
+ this.debounceTimer = setTimeout(() => {
532
+ if (token !== this.searchToken) return;
533
+ for (const source of this.activeChannel.sources) {
534
+ if (this.knownAsync.has(source.id) && !this.invokedForToken.has(source.id)) {
535
+ this.invokeSource(source, token);
536
+ }
537
+ }
538
+ }, this.debounceMs);
539
+ }
540
+
541
+ private invokeSource(
542
+ source: AgentWidgetContextMentionSource,
543
+ token: number
544
+ ): void {
545
+ const ctx = {
546
+ messages: this.opts.getMessages(),
547
+ config: this.opts.getConfig(),
548
+ signal: this.searchAbort!.signal,
549
+ };
550
+ let result:
551
+ | AgentWidgetContextMentionItem[]
552
+ | Promise<AgentWidgetContextMentionItem[]>;
553
+ try {
554
+ result = source.search(this.query, ctx);
555
+ } catch {
556
+ this.setGroupStatus(source.id, "error");
557
+ this.render();
558
+ return;
559
+ }
560
+ if (isThenable(result)) {
561
+ this.knownAsync.add(source.id);
562
+ this.setGroupStatus(source.id, "loading");
563
+ result
564
+ .then((items) => {
565
+ if (token !== this.searchToken) return;
566
+ this.setGroupItems(source.id, items as AgentWidgetContextMentionItem[]);
567
+ this.render();
568
+ })
569
+ .catch(() => {
570
+ if (token !== this.searchToken) return;
571
+ this.setGroupStatus(source.id, "error");
572
+ this.render();
573
+ });
574
+ } else {
575
+ this.setGroupItems(source.id, result);
576
+ }
577
+ }
578
+
579
+ private getOrCreateGroup(source: AgentWidgetContextMentionSource): MentionMenuGroup {
580
+ let group = this.groups.find((g) => g.source.id === source.id);
581
+ if (!group) {
582
+ group = { source, items: [], status: "loading", truncated: false };
583
+ // Preserve source declaration order within the active channel.
584
+ const order = this.activeChannel.sources;
585
+ this.groups.push(group);
586
+ this.groups.sort(
587
+ (a, b) =>
588
+ order.findIndex((s) => s.id === a.source.id) -
589
+ order.findIndex((s) => s.id === b.source.id)
590
+ );
591
+ }
592
+ return group;
593
+ }
594
+
595
+ private setGroupStatus(sourceId: string, status: MentionMenuGroup["status"]): void {
596
+ const source = this.activeChannel.sources.find((s) => s.id === sourceId);
597
+ if (!source) return;
598
+ this.getOrCreateGroup(source).status = status;
599
+ }
600
+
601
+ private setGroupItems(
602
+ sourceId: string,
603
+ items: AgentWidgetContextMentionItem[]
604
+ ): void {
605
+ const source = this.activeChannel.sources.find((s) => s.id === sourceId);
606
+ if (!source) return;
607
+ const group = this.getOrCreateGroup(source);
608
+ group.truncated = items.length > this.maxPerGroup;
609
+ group.items = items.slice(0, this.maxPerGroup);
610
+ group.status = group.items.length === 0 ? "empty" : "ready";
611
+ }
612
+
613
+ /** Stable identity for a flat entry (for tracking the highlight across reorders). */
614
+ private keyOf(entry: {
615
+ source: AgentWidgetContextMentionSource;
616
+ item: AgentWidgetContextMentionItem;
617
+ }): string {
618
+ return JSON.stringify([entry.source.id, entry.item.id]);
619
+ }
620
+
621
+ private rebuildFlat(): void {
622
+ this.flat = [];
623
+ for (const group of this.groups) {
624
+ if (group.status === "ready") {
625
+ for (const item of group.items) this.flat.push({ source: group.source, item });
626
+ }
627
+ }
628
+ // Keep the highlight on the same item when async results reorder the list;
629
+ // fall back to the clamped index (or 0) when that item is no longer present.
630
+ if (this.activeKey) {
631
+ const idx = this.flat.findIndex((e) => this.keyOf(e) === this.activeKey);
632
+ this.activeIndex =
633
+ idx >= 0 ? idx : Math.min(this.activeIndex, Math.max(0, this.flat.length - 1));
634
+ } else if (this.activeIndex >= this.flat.length) {
635
+ this.activeIndex = Math.max(0, this.flat.length - 1);
636
+ }
637
+ }
638
+
639
+ private viewModel() {
640
+ return {
641
+ query: this.query,
642
+ groups: this.groups,
643
+ activeIndex: this.activeIndex,
644
+ };
645
+ }
646
+
647
+ private render(): void {
648
+ this.rebuildFlat();
649
+ this.menu.render(this.viewModel());
650
+ this.syncActiveDescendant();
651
+ this.popover?.reposition();
652
+ // Don't announce "No matches" while results are still loading in.
653
+ const anyLoading = this.groups.some((g) => g.status === "loading");
654
+ if (this.flat.length === 0 && anyLoading) return;
655
+ if (this.flat.length !== this.lastAnnouncedCount) {
656
+ this.lastAnnouncedCount = this.flat.length;
657
+ this.opts.announce(
658
+ this.flat.length === 0
659
+ ? "No matches"
660
+ : this.flat.length === 1
661
+ ? "1 result"
662
+ : `${this.flat.length} results`
663
+ );
664
+ this.opts.emit?.("searched", { query: this.query, results: this.flat.length });
665
+ }
666
+ }
667
+
668
+ private setActiveIndex(index: number, scroll = true): void {
669
+ if (index < 0 || index >= this.flat.length) return;
670
+ this.activeIndex = index;
671
+ this.activeKey = this.keyOf(this.flat[index]);
672
+ this.menu.setActiveIndex(index, scroll);
673
+ this.syncActiveDescendant();
674
+ }
675
+
676
+ /**
677
+ * Mirror the active option id onto the composer textarea so screen readers can
678
+ * track the highlight on the typed-trigger path (focus stays in the textarea
679
+ * there). In picker mode the search field owns focus + `aria-activedescendant`,
680
+ * so the textarea's is cleared.
681
+ */
682
+ private syncActiveDescendant(): void {
683
+ const live =
684
+ this.isOpenState &&
685
+ !this.pickerMode &&
686
+ this.activeIndex >= 0 &&
687
+ this.activeIndex < this.flat.length;
688
+ if (live) {
689
+ this.input.element.setAttribute(
690
+ "aria-activedescendant",
691
+ `${this.listboxId}-opt-${this.activeIndex}`
692
+ );
693
+ } else {
694
+ this.input.element.removeAttribute("aria-activedescendant");
695
+ }
696
+ }
697
+
698
+ /** Re-run a failed source's search for the current query (menu Retry button). */
699
+ private retrySource(sourceId: string): void {
700
+ const source = this.activeChannel.sources.find((s) => s.id === sourceId);
701
+ if (!source || !this.searchAbort) return;
702
+ this.setGroupStatus(sourceId, "loading");
703
+ this.render();
704
+ this.invokeSource(source, this.searchToken);
705
+ // Reflect a synchronous result immediately (async results render on arrival).
706
+ this.render();
707
+ }
708
+
709
+ /** Returns true when the key was consumed (caller must not also handle it). */
710
+ handleKeydown(event: KeyboardEvent): boolean {
711
+ if (!this.isOpenState) return false;
712
+
713
+ switch (event.key) {
714
+ case "ArrowDown": {
715
+ if (this.flat.length === 0) return true;
716
+ event.preventDefault();
717
+ this.setActiveIndex((this.activeIndex + 1) % this.flat.length);
718
+ return true;
719
+ }
720
+ case "ArrowUp": {
721
+ if (this.flat.length === 0) return true;
722
+ event.preventDefault();
723
+ this.setActiveIndex(
724
+ (this.activeIndex - 1 + this.flat.length) % this.flat.length
725
+ );
726
+ return true;
727
+ }
728
+ case "Home": {
729
+ if (this.flat.length === 0) return true;
730
+ event.preventDefault();
731
+ this.setActiveIndex(0);
732
+ return true;
733
+ }
734
+ case "End": {
735
+ if (this.flat.length === 0) return true;
736
+ event.preventDefault();
737
+ this.setActiveIndex(this.flat.length - 1);
738
+ return true;
739
+ }
740
+ case "Enter":
741
+ case "Tab": {
742
+ if (this.flat.length === 0) {
743
+ // Nothing to pick — close and let Enter submit / Tab move on.
744
+ this.close();
745
+ return false;
746
+ }
747
+ event.preventDefault();
748
+ this.selectIndex(this.activeIndex);
749
+ return true;
750
+ }
751
+ case "Escape": {
752
+ // Keep the literal trigger char in the textarea.
753
+ event.preventDefault();
754
+ this.close();
755
+ return true;
756
+ }
757
+ case "Backspace": {
758
+ // Deleting back through the trigger closes the menu; let the keystroke
759
+ // proceed so the trigger char itself is removed.
760
+ if (this.query.length === 0) this.close();
761
+ return false;
762
+ }
763
+ default:
764
+ return false;
765
+ }
766
+ }
767
+
768
+ private selectIndex(index: number): void {
769
+ const entry = this.flat[index];
770
+ if (!entry) return;
771
+ this.commit(entry.source, entry.item);
772
+ }
773
+
774
+ /**
775
+ * Args = the query text after the command name (first token). `"deploy staging"`
776
+ * → `"staging"`; `"deploy"` → `""`. The commands source matches on the first
777
+ * token (via the same {@link splitCommandQuery}), so the item stays selectable
778
+ * while the user types args.
779
+ */
780
+ private deriveArgs(query: string): string {
781
+ return splitCommandQuery(query).args;
782
+ }
783
+
784
+ /**
785
+ * Inline-completion commands: every `command:"server"` item (its chip had no
786
+ * way to add an argument) plus any `"prompt"`/`"action"` item that declares an
787
+ * arg placeholder. Selecting these fills `/name ` into the composer for inline
788
+ * arg entry instead of dispatching now; execution happens at submit.
789
+ */
790
+ private isInlineCommand(item: AgentWidgetContextMentionItem): boolean {
791
+ return item.command === "server" || item.commandArgsPlaceholder != null;
792
+ }
793
+
794
+ private commit(
795
+ source: AgentWidgetContextMentionSource,
796
+ item: AgentWidgetContextMentionItem
797
+ ): void {
798
+ const kind = item.command;
799
+
800
+ // Inline completion (Slack-style): fill `/name ` and let the user type the
801
+ // argument. No chip, no dispatch now — `dispatchInlineCommand` runs it at
802
+ // submit with the typed args.
803
+ if (this.isInlineCommand(item)) {
804
+ this.completeCommandInline(source, item);
805
+ return;
806
+ }
807
+
808
+ // (a) client-action: run and short-circuit. No chip, no send.
809
+ if (kind === "action") {
810
+ const args = this.deriveArgs(this.query);
811
+ this.stripQuery();
812
+ this.close();
813
+ this.runAction(item, args);
814
+ this.opts.emit?.("command", {
815
+ sourceId: source.id,
816
+ itemId: item.id,
817
+ kind: "action",
818
+ args,
819
+ });
820
+ this.input.focus();
821
+ return;
822
+ }
823
+
824
+ // (b) prompt-macro: write resolved text into the composer, optional submit.
825
+ if (kind === "prompt") {
826
+ const args = this.deriveArgs(this.query);
827
+ const target = this.captureStripTarget();
828
+ this.close();
829
+ void this.runPromptMacro(source, item, args, target);
830
+ this.opts.emit?.("command", {
831
+ sourceId: source.id,
832
+ itemId: item.id,
833
+ kind: "prompt",
834
+ args,
835
+ });
836
+ return;
837
+ }
838
+
839
+ // Ordinary `@` mentions. Inline display: insert an atomic token in the prose
840
+ // and track its resolve by composer id. Chip display: go through the manager
841
+ // (chip row + resolve). Server commands never reach here — they inline-complete
842
+ // above.
843
+ if (this.isInlineDisplay()) {
844
+ this.commitInlineMention(source, item);
845
+ return;
846
+ }
847
+
848
+ const ok = this.opts.onSelect(source, item, "");
849
+ if (ok) {
850
+ this.stripQuery();
851
+ this.opts.emit?.("selected", {
852
+ sourceId: source.id,
853
+ itemId: item.id,
854
+ label: item.label,
855
+ });
856
+ }
857
+ this.close();
858
+ this.input.focus();
859
+ }
860
+
861
+ /** True when `@` mentions render as inline tokens (contenteditable composer). */
862
+ private isInlineDisplay(): boolean {
863
+ return (
864
+ this.opts.mentionConfig.display === "inline" &&
865
+ !!this.opts.onInsertMention &&
866
+ !!this.input.insertMentionAtTrigger
867
+ );
868
+ }
869
+
870
+ /**
871
+ * Inline-display commit: insert an atomic mention token (replacing the typed
872
+ * `@query` range, or at the caret for the picker path) and track its resolve
873
+ * keyed by the returned composer id. The limit is gated BEFORE insertion so a
874
+ * rejected pick never leaves a stray token.
875
+ */
876
+ private commitInlineMention(
877
+ source: AgentWidgetContextMentionSource,
878
+ item: AgentWidgetContextMentionItem
879
+ ): void {
880
+ // Limit gate BEFORE insertion (fires the manager's rejection hooks) so a
881
+ // rejected pick never leaves a stray token.
882
+ if (this.opts.admitMention && !this.opts.admitMention(source, item)) {
883
+ this.close();
884
+ this.input.focus();
885
+ return;
886
+ }
887
+ const ref = refFromItem(source, item);
888
+ const id = this.triggerMatch
889
+ ? this.input.insertMentionAtTrigger!(ref, this.triggerMatch)
890
+ : (this.input.insertMentionAtSelection?.(ref) ?? null);
891
+ if (id) {
892
+ this.opts.onInsertMention!(id, source, item, "");
893
+ this.opts.emit?.("selected", {
894
+ sourceId: source.id,
895
+ itemId: item.id,
896
+ label: item.label,
897
+ });
898
+ } else {
899
+ // Insertion refused: the adapter's staleness guard saw the composer text
900
+ // change between parse and commit (an IME/edit race). Fire the same
901
+ // rejection path chip-mode failures use so hosts/tests can react rather than
902
+ // the typed `@query` being silently stranded.
903
+ this.opts.mentionConfig.onMentionRejected?.(item, "stale");
904
+ this.opts.emit?.("rejected", {
905
+ sourceId: source.id,
906
+ itemId: item.id,
907
+ reason: "stale",
908
+ });
909
+ }
910
+ // The `@query` range became the token (typed path); nothing left to strip.
911
+ this.triggerMatch = null;
912
+ this.close();
913
+ this.input.focus();
914
+ }
915
+
916
+ /**
917
+ * Slack-style inline completion: replace the typed trigger query with
918
+ * `<trigger><name> ` so the user types the argument inline. No chip is created
919
+ * and nothing is dispatched — `dispatchInlineCommand` runs the command at
920
+ * submit with the typed args. The trailing space + `isInlineArgTail` keep the
921
+ * menu from reopening while the argument is typed.
922
+ */
923
+ private completeCommandInline(
924
+ source: AgentWidgetContextMentionSource,
925
+ item: AgentWidgetContextMentionItem
926
+ ): void {
927
+ const insert = `${this.activeChannel.trigger}${item.label} `;
928
+ const caret = this.input.getSelection().start;
929
+ // Typed path: replace the `<trigger><query>` span. Picker path (button open,
930
+ // no trigger char present): insert at the caret. Line-start commands only
931
+ // dispatch when they lead a line, so a mid-line picker insert degrades to
932
+ // literal text — acceptable for the rarely-used slash button. Both endpoints
933
+ // are LOGICAL offsets, so the edit must run in logical space (see below).
934
+ const start = this.triggerMatch ? this.triggerMatch.triggerIndex : caret;
935
+ if (this.input.replaceLogicalRange) {
936
+ // Inline: splice at the document level so tokens elsewhere in the line
937
+ // survive (a display-string slice would misalign against `@Label` and
938
+ // re-rendering as one text block would destroy every token).
939
+ this.input.replaceLogicalRange(start, caret, insert);
940
+ } else {
941
+ // Textarea: DISPLAY === LOGICAL, so the string slice is exact.
942
+ const value = this.input.getValue();
943
+ this.input.setValueWithCaret(
944
+ value.slice(0, start) + insert + value.slice(caret),
945
+ start + insert.length
946
+ );
947
+ }
948
+ this.triggerMatch = null;
949
+ this.close();
950
+ this.opts.emit?.("command", {
951
+ sourceId: source.id,
952
+ itemId: item.id,
953
+ kind: item.command ?? "prompt",
954
+ phase: "armed",
955
+ });
956
+ // Re-parse: the trailing space + `isInlineArgTail` keeps the menu closed.
957
+ this.input.dispatchInput();
958
+ this.input.focus();
959
+ }
960
+
961
+ /**
962
+ * True when a command channel's query is a complete inline-command name
963
+ * followed by argument text (`"lookup "`, `"lookup 1042"`). Selecting is done;
964
+ * the menu closes so the list doesn't reappear while the argument is typed.
965
+ */
966
+ private isInlineArgTail(
967
+ channel: NormalizedMentionChannel,
968
+ query: string
969
+ ): boolean {
970
+ const { name } = splitCommandQuery(query);
971
+ if (!name || query.length <= name.length) return false;
972
+ return channel.sources.some((s) => {
973
+ const item = s.matchCommand?.(name);
974
+ return !!item && this.isInlineCommand(item);
975
+ });
976
+ }
977
+
978
+ /**
979
+ * Find a leading inline command in composer TEXT, for submit-time dispatch.
980
+ * For each command channel, if the relevant line begins with the trigger, its
981
+ * first token is looked up via the source's `matchCommand`; only inline
982
+ * commands match. Returns the source, item, and the argument text after the
983
+ * name. Handles menu-selected and hand-typed commands uniformly.
984
+ */
985
+ private matchInlineCommand(text: string): {
986
+ source: AgentWidgetContextMentionSource;
987
+ item: AgentWidgetContextMentionItem;
988
+ args: string;
989
+ } | null {
990
+ for (const channel of this.channels) {
991
+ if (!channel.trigger) continue;
992
+ // Match the live trigger parser: a line-start command may lead ANY line
993
+ // (not just the first); input-start only the very first; anywhere the whole
994
+ // text (unchanged). The command line's remainder after the name is the args.
995
+ const lines =
996
+ channel.position === "anywhere"
997
+ ? [text]
998
+ : channel.position === "line-start"
999
+ ? text.split("\n")
1000
+ : [text.split("\n")[0]];
1001
+ for (const line of lines) {
1002
+ if (!line.startsWith(channel.trigger)) continue;
1003
+ const { name, args } = splitCommandQuery(line.slice(channel.trigger.length));
1004
+ if (!name) continue;
1005
+ for (const source of channel.sources) {
1006
+ const item = source.matchCommand?.(name);
1007
+ if (item && this.isInlineCommand(item)) return { source, item, args };
1008
+ }
1009
+ }
1010
+ }
1011
+ return null;
1012
+ }
1013
+
1014
+ /**
1015
+ * Dispatch a leading inline command in `text` at submit. `action` runs in the
1016
+ * browser (nothing sent); `prompt` resolves to the text to send instead;
1017
+ * `server` returns a mentions bundle (refs empty) whose `finalize` attaches the
1018
+ * resolved `context`. Returns null when `text` isn't an inline command.
1019
+ */
1020
+ async dispatchInlineCommand(text: string): Promise<InlineCommandResult | null> {
1021
+ const match = this.matchInlineCommand(text);
1022
+ if (!match) return null;
1023
+ const { source, item, args } = match;
1024
+ const kind = item.command ?? "prompt";
1025
+ this.opts.emit?.("command", {
1026
+ sourceId: source.id,
1027
+ itemId: item.id,
1028
+ kind,
1029
+ args,
1030
+ });
1031
+
1032
+ if (kind === "action") {
1033
+ this.runAction(item, args);
1034
+ return { kind: "action" };
1035
+ }
1036
+
1037
+ if (kind === "prompt") {
1038
+ let payload;
1039
+ try {
1040
+ payload = await source.resolve(item, this.resolveContext(args, text));
1041
+ } catch (error) {
1042
+ if (typeof console !== "undefined") {
1043
+ console.warn("[Persona] inline prompt command resolve failed", error);
1044
+ }
1045
+ return null; // send the typed text as-is
1046
+ }
1047
+ return { kind: "prompt", sendText: payload.insertText ?? payload.llmAppend ?? text };
1048
+ }
1049
+
1050
+ // server: resolve at submit; refs empty (no chip), context namespaced.
1051
+ const finalize = async (): Promise<MentionSubmitBundle> => {
1052
+ const payload = await source.resolve(item, this.resolveContext(args, text));
1053
+ const context: Record<string, Record<string, unknown>> = {};
1054
+ if (payload.context) context[source.id] = { [item.id]: payload.context };
1055
+ return {
1056
+ blocks: [],
1057
+ contentParts: payload.contentParts ?? [],
1058
+ context,
1059
+ };
1060
+ };
1061
+ return { kind: "server", mentions: { refs: [], finalize } };
1062
+ }
1063
+
1064
+ private resolveContext(args: string, composerText: string) {
1065
+ return {
1066
+ messages: this.opts.getMessages(),
1067
+ config: this.opts.getConfig(),
1068
+ composerText,
1069
+ args,
1070
+ signal: new AbortController().signal,
1071
+ };
1072
+ }
1073
+
1074
+ /** Run a `command:"action"` handler, guarding against throws. */
1075
+ private runAction(item: AgentWidgetContextMentionItem, args: string): void {
1076
+ if (!item.action) return;
1077
+ try {
1078
+ void Promise.resolve(
1079
+ item.action({
1080
+ args,
1081
+ config: this.opts.getConfig(),
1082
+ messages: this.opts.getMessages(),
1083
+ composer: this.input,
1084
+ })
1085
+ ).catch((error) => {
1086
+ if (typeof console !== "undefined") {
1087
+ console.warn("[Persona] context-mention command action failed", error);
1088
+ }
1089
+ });
1090
+ } catch (error) {
1091
+ if (typeof console !== "undefined") {
1092
+ console.warn("[Persona] context-mention command action failed", error);
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ /**
1098
+ * Snapshot the trigger span for an insert-at-caret prompt macro: the LOGICAL
1099
+ * range `[triggerIndex, caret)` to rewrite, plus the display `value` at select
1100
+ * time for the textarea fallback (where DISPLAY === LOGICAL).
1101
+ */
1102
+ private captureStripTarget(): {
1103
+ value: string;
1104
+ start: number;
1105
+ end: number;
1106
+ } | null {
1107
+ if (!this.triggerMatch) return null;
1108
+ return {
1109
+ value: this.input.getValue(),
1110
+ start: this.triggerMatch.triggerIndex,
1111
+ end: this.input.getSelection().start,
1112
+ };
1113
+ }
1114
+
1115
+ /** Resolve a `command:"prompt"` macro and write its text into the composer. */
1116
+ private async runPromptMacro(
1117
+ source: AgentWidgetContextMentionSource,
1118
+ item: AgentWidgetContextMentionItem,
1119
+ args: string,
1120
+ target: { value: string; start: number; end: number } | null
1121
+ ): Promise<void> {
1122
+ const composer = this.input;
1123
+ let payload;
1124
+ try {
1125
+ payload = await source.resolve(item, {
1126
+ messages: this.opts.getMessages(),
1127
+ config: this.opts.getConfig(),
1128
+ composerText: composer.getValue(),
1129
+ args,
1130
+ signal: new AbortController().signal,
1131
+ });
1132
+ } catch (error) {
1133
+ if (typeof console !== "undefined") {
1134
+ console.warn("[Persona] context-mention prompt resolve failed", error);
1135
+ }
1136
+ return;
1137
+ }
1138
+ const text = payload.insertText ?? payload.llmAppend ?? "";
1139
+ if (item.insertMode === "insert-at-caret" && target) {
1140
+ if (composer.replaceLogicalRange) {
1141
+ // Inline: splice the `/command` span in logical space, preserving tokens
1142
+ // elsewhere in the prose. `setValue` would flatten the whole document.
1143
+ composer.replaceLogicalRange(target.start, target.end, text);
1144
+ composer.dispatchInput();
1145
+ } else {
1146
+ // Textarea: DISPLAY === LOGICAL, so splice the display snapshot.
1147
+ composer.setValue(
1148
+ target.value.slice(0, target.start) + text + target.value.slice(target.end)
1149
+ );
1150
+ }
1151
+ } else {
1152
+ composer.setValue(text);
1153
+ }
1154
+ if (item.submitOnSelect) composer.submit();
1155
+ }
1156
+
1157
+ private stripQuery(): void {
1158
+ if (!this.triggerMatch) return;
1159
+ const caret = this.input.getSelection().start;
1160
+ if (this.input.replaceLogicalRange) {
1161
+ // Inline: remove the `@query` span in logical space, preserving every token
1162
+ // outside `[triggerIndex, caret)`. A display-string strip would misalign and
1163
+ // a full re-render would destroy the tokens.
1164
+ this.input.replaceLogicalRange(this.triggerMatch.triggerIndex, caret, "");
1165
+ } else {
1166
+ // Textarea: DISPLAY === LOGICAL, so the string strip is exact.
1167
+ const out = stripMentionQuery(this.input.getValue(), this.triggerMatch, caret);
1168
+ this.input.setValueWithCaret(out.value, out.caret);
1169
+ }
1170
+ // Auto-resize listener reacts to input; our re-parse yields null → stays closed.
1171
+ this.input.dispatchInput();
1172
+ this.triggerMatch = null;
1173
+ }
1174
+
1175
+ destroy(): void {
1176
+ this.disconnectComposerResize();
1177
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
1178
+ this.searchAbort?.abort();
1179
+ this.popover?.destroy();
1180
+ this.menu.destroy();
1181
+ const el = this.input.element;
1182
+ el.removeAttribute("aria-haspopup");
1183
+ el.removeAttribute("aria-controls");
1184
+ el.removeAttribute("aria-activedescendant");
1185
+ }
1186
+ }