@runtypelabs/persona 4.25.0 → 4.26.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 (236) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +180 -8
  12. package/dist/approval-ui.d.ts +180 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +180 -8
  17. package/dist/artifacts-ui.d.ts +180 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-HGUCGM3K.js +9 -0
  23. package/dist/chunk-IO5VVUKP.js +3 -0
  24. package/dist/chunk-J4J3Y2P2.js +1 -0
  25. package/dist/chunk-K6HKIP5K.js +1 -0
  26. package/dist/chunk-K7NA7G7J.js +1 -0
  27. package/dist/chunk-MXUTS3LB.js +1 -0
  28. package/dist/chunk-PDPVORUF.js +1 -0
  29. package/dist/chunk-QPPJUSZH.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-YPEG45Q4.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +180 -8
  46. package/dist/context-mentions.d.ts +180 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +183 -31
  51. package/dist/event-stream-view.d.ts +183 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +221 -667
  54. package/dist/forms-ui.d.ts +221 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +227 -738
  65. package/dist/index.d.ts +227 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +180 -8
  80. package/dist/session-reconnect.d.ts +180 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +180 -8
  83. package/dist/smart-dom-reader.d.ts +180 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +182 -8
  87. package/dist/theme-editor-preview.d.ts +182 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +181 -23
  91. package/dist/theme-editor.d.ts +181 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-UOQOFAFY.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11650 -232
  100. package/dist/voice-runtime.d.ts +11650 -232
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +221 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +6 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +852 -1536
  151. package/src/session.voice.test.ts +1170 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +198 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +340 -2770
  172. package/src/utils/attachment-manager.ts +67 -67
  173. package/src/utils/constants.ts +3 -0
  174. package/src/utils/context-mention-controller.ts +323 -323
  175. package/src/utils/context-mention-manager.ts +40 -40
  176. package/src/utils/context-mention-orchestrator.ts +28 -0
  177. package/src/utils/event-stream-buffer.ts +50 -50
  178. package/src/utils/event-stream-store.ts +43 -43
  179. package/src/utils/throughput-tracker.ts +34 -34
  180. package/src/utils/virtual-scroller.ts +67 -67
  181. package/src/utils/visitor-store.test.ts +50 -0
  182. package/src/utils/visitor-store.ts +23 -5
  183. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  184. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  185. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  186. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  189. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  190. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  191. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  192. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  193. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  195. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  198. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  199. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  200. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  201. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  202. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  206. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  207. package/src/voice/audio-playback-manager.test.ts +57 -0
  208. package/src/voice/audio-playback-manager.ts +120 -98
  209. package/src/voice/browser-voice-provider.ts +37 -37
  210. package/src/voice/fallback-speech-engine.ts +22 -16
  211. package/src/voice/keyed-voice-transcript.ts +314 -0
  212. package/src/voice/mic-state-styles.ts +182 -0
  213. package/src/voice/read-aloud-controller.ts +46 -44
  214. package/src/voice/runtype-speech-engine.ts +35 -33
  215. package/src/voice/runtype-voice-provider.ts +670 -189
  216. package/src/voice/session-voice-wiring.ts +266 -0
  217. package/src/voice/voice-delegation.test.ts +71 -0
  218. package/src/voice/voice-delegation.ts +364 -0
  219. package/src/voice/voice-turn-tracker.ts +6 -6
  220. package/src/voice/voice.test.ts +981 -8
  221. package/src/voice/worklet-playback-engine.test.ts +81 -0
  222. package/src/voice/worklet-playback-engine.ts +21 -3
  223. package/src/voice-runtime-loader.ts +4 -1
  224. package/src/voice-runtime.ts +7 -0
  225. package/src/webmcp-runtime-entry.ts +41 -39
  226. package/dist/approval-ui-3NMYNXBD.js +0 -1
  227. package/dist/chunk-5EIIHQLQ.js +0 -1
  228. package/dist/chunk-7CO4T5KB.js +0 -1
  229. package/dist/chunk-EFJBUH5F.js +0 -1
  230. package/dist/chunk-R2TMIMDI.js +0 -3
  231. package/dist/chunk-UTVLOTWB.js +0 -9
  232. package/dist/context-mentions-45MODWOY.js +0 -161
  233. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  234. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  235. package/dist/voice-runtime-7DA7MJH4.js +0 -1
  236. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -115,63 +115,63 @@ let listboxSeq = 0;
115
115
  * the manager's job (reached via `onSelect`).
116
116
  */
117
117
  export class ContextMentionController {
118
- private readonly opts: ContextMentionControllerOptions;
118
+ readonly #opts: ContextMentionControllerOptions;
119
119
  /** Primary `@` channel + any extra `triggers` channels; one drives the menu. */
120
- private readonly channels: NormalizedMentionChannel[];
120
+ readonly #channels: NormalizedMentionChannel[];
121
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;
122
+ #activeChannel: NormalizedMentionChannel;
123
+ readonly #maxPerGroup: number;
124
+ readonly #debounceMs: number;
125
+ readonly #menu: MentionMenuParts;
126
+ readonly #listboxId: string;
127
127
 
128
- private popover: PopoverHandle | null = null;
128
+ #popover: PopoverHandle | null = null;
129
129
  // Watches the composer box while the menu is open: an auto-grow line-wrap moves
130
130
  // the `@` glyph (x changes) and shifts the composer's edges (the upward menu's
131
131
  // top changes), so on each resize we re-measure the trigger anchor and
132
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;
133
+ #resizeObserver: ResizeObserver | null = null;
134
+ #isOpenState = false;
135
+ #query = "";
136
+ #triggerMatch: MentionTriggerMatch | null = null;
137
+ #activeIndex = 0;
138
138
  /** Identity (`sourceId, itemId`) of the highlighted row, so the highlight
139
139
  * follows the same item when async results reorder the flat list. */
140
- private activeKey: string | null = null;
140
+ #activeKey: string | null = null;
141
141
  // True while the menu was opened from the affordance button as a picker: no
142
142
  // trigger char is in the textarea, so the in-menu search field owns the query
143
143
  // and there is nothing to strip on close.
144
- private pickerMode = false;
144
+ #pickerMode = false;
145
145
  // The trigger of the channel whose button opened the current picker, so the
146
146
  // close path can address the same button's aria-expanded even though `close()`
147
147
  // has no channel in hand.
148
- private pickerTrigger: string | null = null;
148
+ #pickerTrigger: string | null = null;
149
149
 
150
- private groups: MentionMenuGroup[] = [];
151
- private flat: {
150
+ #groups: MentionMenuGroup[] = [];
151
+ #flat: {
152
152
  source: AgentWidgetContextMentionSource;
153
153
  item: AgentWidgetContextMentionItem;
154
154
  }[] = [];
155
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>();
156
+ #searchToken = 0;
157
+ #searchAbort: AbortController | null = null;
158
+ #debounceTimer: ReturnType<typeof setTimeout> | null = null;
159
+ readonly #knownAsync = new Set<string>();
160
160
  // Source ids invoked in setQuery's immediate pass for the CURRENT token, so the
161
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;
162
+ readonly #invokedForToken = new Set<string>();
163
+ #lastAnnouncedCount = -1;
164
164
 
165
165
  // Trigger-anchored menu positioning (inline mode). `triggerAnchorOffset` holds
166
166
  // the trigger glyph's x/y deltas from the composer's left/top edges, measured
167
167
  // ONCE per trigger session (see `updateTriggerAnchor`). Deltas survive scroll/pan;
168
168
  // the popover adds them to the live anchor rect each reposition instead of
169
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;
170
+ #triggerAnchorOffset: { x: number | null; y: number } | null = null;
171
+ #measuredTriggerIndex: number | null = null;
172
172
 
173
173
  constructor(opts: ContextMentionControllerOptions) {
174
- this.opts = opts;
174
+ this.#opts = opts;
175
175
  const cfg = opts.mentionConfig;
176
176
  // One shared normalizer (primary `@` channel + any extra `triggers`); the
177
177
  // engine drives them all from one menu. Drop empty channels (e.g. the default
@@ -179,25 +179,25 @@ export class ContextMentionController {
179
179
  // config somehow declared none (the orchestrator gates this case upstream).
180
180
  const all = normalizeMentionChannels(cfg);
181
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()
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
190
  : createMentionMenu({
191
191
  config: opts.mentionConfig,
192
- listboxId: this.listboxId,
193
- onSelectIndex: (i) => this.selectIndex(i),
192
+ listboxId: this.#listboxId,
193
+ onSelectIndex: (i) => this.#selectIndex(i),
194
194
  // Hover highlights without scrolling (keyboard scrolls; a moving cursor
195
195
  // shouldn't yank the list).
196
- onHoverIndex: (i) => this.setActiveIndex(i, false),
197
- onRetry: (sourceId) => this.retrySource(sourceId),
196
+ onHoverIndex: (i) => this.#setActiveIndex(i, false),
197
+ onRetry: (sourceId) => this.#retrySource(sourceId),
198
198
  // Picker mode: the in-menu search field is the query source and drives
199
199
  // keyboard nav, since focus lives in it (not the textarea).
200
- onSearchInput: (value) => this.setQuery(value),
200
+ onSearchInput: (value) => this.#setQuery(value),
201
201
  onSearchKeydown: (event) => {
202
202
  this.handleKeydown(event);
203
203
  },
@@ -205,7 +205,7 @@ export class ContextMentionController {
205
205
 
206
206
  const el = opts.composerInput.element;
207
207
  el.setAttribute("aria-haspopup", "listbox");
208
- el.setAttribute("aria-controls", this.listboxId);
208
+ el.setAttribute("aria-controls", this.#listboxId);
209
209
  // NB: no aria-expanded here. The editable surface keeps role="textbox" +
210
210
  // aria-multiline (so it retains multiline editing semantics), and
211
211
  // aria-expanded is not a supported state on role="textbox" — promoting it to
@@ -216,26 +216,26 @@ export class ContextMentionController {
216
216
 
217
217
  /** The composer input surface — all text/selection ops route through this. */
218
218
  private get input(): ComposerInputCapability {
219
- return this.opts.composerInput;
219
+ return this.#opts.composerInput;
220
220
  }
221
221
 
222
222
  // The host-render path wraps `renderMentionMenu` output in a positioned shell;
223
223
  // positioning, trigger detection, search, and keyboard stay ours. The host
224
224
  // owns its own highlight, so `setActiveIndex` just re-renders.
225
- private createHostMenu(): MentionMenuParts {
225
+ #createHostMenu(): MentionMenuParts {
226
226
  const el = document.createElement("div");
227
227
  el.setAttribute("data-persona-mention-menu", "");
228
228
  el.setAttribute("role", "listbox");
229
- el.id = this.listboxId;
229
+ el.id = this.#listboxId;
230
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,
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
236
  select: (item) => {
237
- const group = this.groups.find((g) => g.items.includes(item));
238
- if (group) this.commit(group.source, item);
237
+ const group = this.#groups.find((g) => g.items.includes(item));
238
+ if (group) this.#commit(group.source, item);
239
239
  },
240
240
  close: () => this.close(),
241
241
  });
@@ -250,7 +250,7 @@ export class ContextMentionController {
250
250
  }
251
251
 
252
252
  isOpen(): boolean {
253
- return this.isOpenState;
253
+ return this.#isOpenState;
254
254
  }
255
255
 
256
256
  /**
@@ -266,8 +266,8 @@ export class ContextMentionController {
266
266
  */
267
267
  openFromButton(trigger?: string): void {
268
268
  const channel =
269
- (trigger && this.channels.find((c) => c.trigger === trigger)) ||
270
- this.channels[0];
269
+ (trigger && this.#channels.find((c) => c.trigger === trigger)) ||
270
+ this.#channels[0];
271
271
 
272
272
  // If a live trigger token for THIS channel already sits at the caret (the
273
273
  // user typed `@que` then clicked the button), adopt it as a normal typed
@@ -276,13 +276,13 @@ export class ContextMentionController {
276
276
  const caret = this.input.getSelection().start;
277
277
  const typed = parseAnyTrigger(this.input.getLogicalText(), caret, [channel]);
278
278
  if (typed) {
279
- this.pickerMode = false;
280
- this.triggerMatch = typed.match;
281
- if (!this.isOpenState) {
282
- this.open(typed.match.query, channel);
279
+ this.#pickerMode = false;
280
+ this.#triggerMatch = typed.match;
281
+ if (!this.#isOpenState) {
282
+ this.#open(typed.match.query, channel);
283
283
  } else {
284
- this.switchChannel(channel);
285
- this.setQuery(typed.match.query);
284
+ this.#switchChannel(channel);
285
+ this.#setQuery(typed.match.query);
286
286
  }
287
287
  this.input.focus();
288
288
  return;
@@ -291,90 +291,90 @@ export class ContextMentionController {
291
291
  // Switching to a different channel's picker while one is already open must
292
292
  // close the previous button's state first; close() later emits false for the
293
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);
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
300
  } else {
301
301
  if (prevPickerTrigger && prevPickerTrigger !== channel.trigger) {
302
- this.opts.onPickerOpenChange?.(false, prevPickerTrigger, this.listboxId);
302
+ this.#opts.onPickerOpenChange?.(false, prevPickerTrigger, this.#listboxId);
303
303
  }
304
- this.switchChannel(channel);
305
- this.setQuery("");
304
+ this.#switchChannel(channel);
305
+ this.#setQuery("");
306
306
  }
307
- if (this.menu.showSearch) this.menu.showSearch("", channel.searchPlaceholder);
307
+ if (this.#menu.showSearch) this.#menu.showSearch("", channel.searchPlaceholder);
308
308
  else this.input.focus();
309
- this.opts.onPickerOpenChange?.(true, channel.trigger, this.listboxId);
309
+ this.#opts.onPickerOpenChange?.(true, channel.trigger, this.#listboxId);
310
310
  }
311
311
 
312
312
  /** Re-parse the composer on every input and open/update/close the menu. */
313
313
  onInput(): void {
314
314
  const caret = this.input.getSelection().start;
315
- const hit = parseAnyTrigger(this.input.getLogicalText(), caret, this.channels);
315
+ const hit = parseAnyTrigger(this.input.getLogicalText(), caret, this.#channels);
316
316
  if (!hit) {
317
- if (this.isOpenState) this.close();
317
+ if (this.#isOpenState) this.close();
318
318
  return;
319
319
  }
320
- if (this.isInlineArgTail(hit.channel, hit.match.query)) {
320
+ if (this.#isInlineArgTail(hit.channel, hit.match.query)) {
321
321
  // The command name is complete and the caret is into the argument — the
322
322
  // menu has nothing left to offer, so keep it closed (Slack-style). The
323
323
  // command runs at submit via `dispatchInlineCommand`.
324
- this.triggerMatch = null;
325
- if (this.isOpenState) this.close(false);
324
+ this.#triggerMatch = null;
325
+ if (this.#isOpenState) this.close(false);
326
326
  return;
327
327
  }
328
- this.triggerMatch = hit.match;
329
- if (!this.isOpenState) {
330
- this.open(hit.match.query, hit.channel);
328
+ this.#triggerMatch = hit.match;
329
+ if (!this.#isOpenState) {
330
+ this.#open(hit.match.query, hit.channel);
331
331
  } else {
332
- this.switchChannel(hit.channel);
332
+ this.#switchChannel(hit.channel);
333
333
  // Re-anchor only when the trigger MOVED (new session / edit before the `@`).
334
334
  // Plain query typing keeps the same `triggerIndex`, so the menu stays put —
335
335
  // no per-keystroke layout work (Slack behavior).
336
- if (this.measuredTriggerIndex !== hit.match.triggerIndex) {
337
- this.updateTriggerAnchor();
336
+ if (this.#measuredTriggerIndex !== hit.match.triggerIndex) {
337
+ this.#updateTriggerAnchor();
338
338
  }
339
- this.setQuery(hit.match.query);
339
+ this.#setQuery(hit.match.query);
340
340
  }
341
341
  }
342
342
 
343
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;
344
+ #switchChannel(channel: NormalizedMentionChannel): void {
345
+ if (channel === this.#activeChannel) return;
346
+ this.#activeChannel = channel;
347
+ this.#groups = [];
348
+ this.#activeIndex = 0;
349
349
  }
350
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) {
351
+ #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
358
  // Inline mode with a measurable composer trigger-anchors the menu to the `@`
359
359
  // glyph (Slack-style): the menu is content-sized and shifts horizontally.
360
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,
361
+ const anchored = this.#canAnchorMenu();
362
+ this.#popover = createPopover({
363
+ anchor: this.#opts.anchor,
364
+ content: this.#menu.el,
365
365
  placement: "top-start",
366
366
  // Full-width menu only in composer-anchored mode; the trigger-anchored
367
367
  // menu must be content-sized so it has room to shift within the composer.
368
368
  matchAnchorWidth: !anchored,
369
369
  offset: 6,
370
- container: this.opts.popoverContainer,
370
+ container: this.#opts.popoverContainer,
371
371
  // Trigger-anchored offsets (px from the composer's left/top edges), or
372
372
  // null → composer-anchored fallback on that axis. Omitted when not anchored.
373
373
  horizontalOffset: anchored
374
- ? () => this.triggerAnchorOffset?.x ?? null
374
+ ? () => this.#triggerAnchorOffset?.x ?? null
375
375
  : undefined,
376
376
  verticalOffset: anchored
377
- ? () => this.triggerAnchorOffset?.y ?? null
377
+ ? () => this.#triggerAnchorOffset?.y ?? null
378
378
  : undefined,
379
379
  // Outside-click / anchor-removed: run the SAME teardown as an explicit
380
380
  // close so the debounce timer, in-flight search, and search token are
@@ -389,23 +389,23 @@ export class ContextMentionController {
389
389
  // resolve against the viewport (the popover is fixed-positioned) and, as
390
390
  // min-width beats max-width, overflow composers narrower than 220px.
391
391
  if (anchored) {
392
- const anchorWidth = this.opts.anchor.getBoundingClientRect().width;
393
- this.menu.el.style.minWidth = `${Math.min(220, anchorWidth)}px`;
392
+ const anchorWidth = this.#opts.anchor.getBoundingClientRect().width;
393
+ this.#menu.el.style.minWidth = `${Math.min(220, anchorWidth)}px`;
394
394
  }
395
395
  }
396
396
  // Measure the trigger glyph ONCE for this session (before the first reposition
397
397
  // in setQuery). Re-measures only on a trigger-index change (new session); the
398
398
  // popover's own scroll/resize reposition reuses the cached anchor-relative
399
399
  // delta, so panning/scrolling stays aligned without re-measuring.
400
- this.updateTriggerAnchor();
401
- this.popover.open();
400
+ this.#updateTriggerAnchor();
401
+ this.#popover.open();
402
402
  // Inject the menu CSS into whatever root the menu now lives in (document
403
403
  // head by default; the shadow root under `useShadowDom`). Idempotent per
404
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);
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
409
  }
410
410
 
411
411
  /**
@@ -418,19 +418,19 @@ export class ContextMentionController {
418
418
  * there is no observer feedback loop. Degrades silently where ResizeObserver is
419
419
  * unavailable (e.g. jsdom), so behavior falls back to scroll/window-resize only.
420
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();
421
+ #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
427
  });
428
- this.resizeObserver.observe(this.opts.anchor);
428
+ this.#resizeObserver.observe(this.#opts.anchor);
429
429
  }
430
430
 
431
- private disconnectComposerResize(): void {
432
- this.resizeObserver?.disconnect();
433
- this.resizeObserver = null;
431
+ #disconnectComposerResize(): void {
432
+ this.#resizeObserver?.disconnect();
433
+ this.#resizeObserver = null;
434
434
  }
435
435
 
436
436
  /**
@@ -438,8 +438,8 @@ export class ContextMentionController {
438
438
  * composer that can measure a logical range rect. Chip/textarea mode (no
439
439
  * `getLogicalRangeRect`) keeps the composer-anchored menu — graceful degradation.
440
440
  */
441
- private canAnchorMenu(): boolean {
442
- return this.isInlineDisplay() && !!this.input.getLogicalRangeRect;
441
+ #canAnchorMenu(): boolean {
442
+ return this.#isInlineDisplay() && !!this.input.getLogicalRangeRect;
443
443
  }
444
444
 
445
445
  /**
@@ -454,136 +454,136 @@ export class ContextMentionController {
454
454
  * `triggerIndex`; a non-collapsed range around it measures reliably (a collapsed
455
455
  * boundary range measures empty).
456
456
  */
457
- private updateTriggerAnchor(): void {
458
- const match = this.triggerMatch;
457
+ #updateTriggerAnchor(): void {
458
+ const match = this.#triggerMatch;
459
459
  const measure = this.input.getLogicalRangeRect;
460
- this.measuredTriggerIndex = match?.triggerIndex ?? null;
461
- this.triggerAnchorOffset = null;
460
+ this.#measuredTriggerIndex = match?.triggerIndex ?? null;
461
+ this.#triggerAnchorOffset = null;
462
462
  if (!match || !measure) return;
463
463
  const rect = measure(match.triggerIndex, match.triggerIndex + 1);
464
464
  if (!rect) return;
465
- const anchorRect = this.opts.anchor.getBoundingClientRect();
465
+ const anchorRect = this.#opts.anchor.getBoundingClientRect();
466
466
  const y = rect.top - anchorRect.top;
467
467
  const el = this.input.element;
468
468
  const rtl =
469
469
  typeof getComputedStyle === "function" &&
470
470
  getComputedStyle(el).direction === "rtl";
471
471
  // RTL: horizontal trigger anchoring is left-to-right math; fall back on x only.
472
- this.triggerAnchorOffset = {
472
+ this.#triggerAnchorOffset = {
473
473
  x: rtl ? null : rect.left - anchorRect.left,
474
474
  y,
475
475
  };
476
476
  }
477
477
 
478
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();
479
+ if (!this.#isOpenState) return;
480
+ this.#isOpenState = false;
481
+ this.#disconnectComposerResize();
482
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
483
+ this.#searchAbort?.abort();
484
484
  // Invalidate any in-flight async search so late results can't render,
485
485
  // announce, or emit into a closed menu.
486
- this.searchToken++;
487
- this.popover?.close();
486
+ this.#searchToken++;
487
+ this.#popover?.close();
488
488
  this.input.element.removeAttribute("aria-activedescendant");
489
- if (this.pickerMode) {
489
+ if (this.#pickerMode) {
490
490
  // Picker teardown: hide the search field and (unless dismissed by an
491
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?.(
492
+ this.#pickerMode = false;
493
+ this.#menu.hideSearch?.();
494
+ this.#opts.onPickerOpenChange?.(
495
495
  false,
496
- this.pickerTrigger ?? this.activeChannel.trigger,
497
- this.listboxId
496
+ this.#pickerTrigger ?? this.#activeChannel.trigger,
497
+ this.#listboxId
498
498
  );
499
- this.pickerTrigger = null;
499
+ this.#pickerTrigger = null;
500
500
  if (refocus) this.input.focus();
501
501
  }
502
502
  }
503
503
 
504
- private setQuery(query: string): void {
505
- this.query = query;
504
+ #setQuery(query: string): void {
505
+ this.#query = query;
506
506
  // A new query resets the highlight to the first result (standard
507
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();
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
514
 
515
515
  // Immediate pass: sync sources (and first-time sources) render with zero
516
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");
517
+ for (const source of this.#activeChannel.sources) {
518
+ if (this.#knownAsync.has(source.id)) {
519
+ this.#setGroupStatus(source.id, "loading");
520
520
  } else {
521
- this.invokedForToken.add(source.id);
522
- this.invokeSource(source, token);
521
+ this.#invokedForToken.add(source.id);
522
+ this.#invokeSource(source, token);
523
523
  }
524
524
  }
525
- this.render();
525
+ this.#render();
526
526
 
527
527
  // Debounced pass: re-invoke the known-async (network) sources, skipping any a
528
528
  // first-time invoke already fired for this token (invokeSource marks a source
529
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);
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
536
  }
537
537
  }
538
- }, this.debounceMs);
538
+ }, this.#debounceMs);
539
539
  }
540
540
 
541
- private invokeSource(
541
+ #invokeSource(
542
542
  source: AgentWidgetContextMentionSource,
543
543
  token: number
544
544
  ): void {
545
545
  const ctx = {
546
- messages: this.opts.getMessages(),
547
- config: this.opts.getConfig(),
548
- signal: this.searchAbort!.signal,
546
+ messages: this.#opts.getMessages(),
547
+ config: this.#opts.getConfig(),
548
+ signal: this.#searchAbort!.signal,
549
549
  };
550
550
  let result:
551
551
  | AgentWidgetContextMentionItem[]
552
552
  | Promise<AgentWidgetContextMentionItem[]>;
553
553
  try {
554
- result = source.search(this.query, ctx);
554
+ result = source.search(this.#query, ctx);
555
555
  } catch {
556
- this.setGroupStatus(source.id, "error");
557
- this.render();
556
+ this.#setGroupStatus(source.id, "error");
557
+ this.#render();
558
558
  return;
559
559
  }
560
560
  if (isThenable(result)) {
561
- this.knownAsync.add(source.id);
562
- this.setGroupStatus(source.id, "loading");
561
+ this.#knownAsync.add(source.id);
562
+ this.#setGroupStatus(source.id, "loading");
563
563
  result
564
564
  .then((items) => {
565
- if (token !== this.searchToken) return;
566
- this.setGroupItems(source.id, items as AgentWidgetContextMentionItem[]);
567
- this.render();
565
+ if (token !== this.#searchToken) return;
566
+ this.#setGroupItems(source.id, items as AgentWidgetContextMentionItem[]);
567
+ this.#render();
568
568
  })
569
569
  .catch(() => {
570
- if (token !== this.searchToken) return;
571
- this.setGroupStatus(source.id, "error");
572
- this.render();
570
+ if (token !== this.#searchToken) return;
571
+ this.#setGroupStatus(source.id, "error");
572
+ this.#render();
573
573
  });
574
574
  } else {
575
- this.setGroupItems(source.id, result);
575
+ this.#setGroupItems(source.id, result);
576
576
  }
577
577
  }
578
578
 
579
- private getOrCreateGroup(source: AgentWidgetContextMentionSource): MentionMenuGroup {
580
- let group = this.groups.find((g) => g.source.id === source.id);
579
+ #getOrCreateGroup(source: AgentWidgetContextMentionSource): MentionMenuGroup {
580
+ let group = this.#groups.find((g) => g.source.id === source.id);
581
581
  if (!group) {
582
582
  group = { source, items: [], status: "loading", truncated: false };
583
583
  // Preserve source declaration order within the active channel.
584
- const order = this.activeChannel.sources;
585
- this.groups.push(group);
586
- this.groups.sort(
584
+ const order = this.#activeChannel.sources;
585
+ this.#groups.push(group);
586
+ this.#groups.sort(
587
587
  (a, b) =>
588
588
  order.findIndex((s) => s.id === a.source.id) -
589
589
  order.findIndex((s) => s.id === b.source.id)
@@ -592,85 +592,85 @@ export class ContextMentionController {
592
592
  return group;
593
593
  }
594
594
 
595
- private setGroupStatus(sourceId: string, status: MentionMenuGroup["status"]): void {
596
- const source = this.activeChannel.sources.find((s) => s.id === sourceId);
595
+ #setGroupStatus(sourceId: string, status: MentionMenuGroup["status"]): void {
596
+ const source = this.#activeChannel.sources.find((s) => s.id === sourceId);
597
597
  if (!source) return;
598
- this.getOrCreateGroup(source).status = status;
598
+ this.#getOrCreateGroup(source).status = status;
599
599
  }
600
600
 
601
- private setGroupItems(
601
+ #setGroupItems(
602
602
  sourceId: string,
603
603
  items: AgentWidgetContextMentionItem[]
604
604
  ): void {
605
- const source = this.activeChannel.sources.find((s) => s.id === sourceId);
605
+ const source = this.#activeChannel.sources.find((s) => s.id === sourceId);
606
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);
607
+ const group = this.#getOrCreateGroup(source);
608
+ group.truncated = items.length > this.#maxPerGroup;
609
+ group.items = items.slice(0, this.#maxPerGroup);
610
610
  group.status = group.items.length === 0 ? "empty" : "ready";
611
611
  }
612
612
 
613
613
  /** Stable identity for a flat entry (for tracking the highlight across reorders). */
614
- private keyOf(entry: {
614
+ #keyOf(entry: {
615
615
  source: AgentWidgetContextMentionSource;
616
616
  item: AgentWidgetContextMentionItem;
617
617
  }): string {
618
618
  return JSON.stringify([entry.source.id, entry.item.id]);
619
619
  }
620
620
 
621
- private rebuildFlat(): void {
622
- this.flat = [];
623
- for (const group of this.groups) {
621
+ #rebuildFlat(): void {
622
+ this.#flat = [];
623
+ for (const group of this.#groups) {
624
624
  if (group.status === "ready") {
625
- for (const item of group.items) this.flat.push({ source: group.source, item });
625
+ for (const item of group.items) this.#flat.push({ source: group.source, item });
626
626
  }
627
627
  }
628
628
  // Keep the highlight on the same item when async results reorder the list;
629
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);
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
636
  }
637
637
  }
638
638
 
639
- private viewModel() {
639
+ #viewModel() {
640
640
  return {
641
- query: this.query,
642
- groups: this.groups,
643
- activeIndex: this.activeIndex,
641
+ query: this.#query,
642
+ groups: this.#groups,
643
+ activeIndex: this.#activeIndex,
644
644
  };
645
645
  }
646
646
 
647
- private render(): void {
648
- this.rebuildFlat();
649
- this.menu.render(this.viewModel());
650
- this.syncActiveDescendant();
651
- this.popover?.reposition();
647
+ #render(): void {
648
+ this.#rebuildFlat();
649
+ this.#menu.render(this.#viewModel());
650
+ this.#syncActiveDescendant();
651
+ this.#popover?.reposition();
652
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
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
659
  ? "No matches"
660
- : this.flat.length === 1
660
+ : this.#flat.length === 1
661
661
  ? "1 result"
662
- : `${this.flat.length} results`
662
+ : `${this.#flat.length} results`
663
663
  );
664
- this.opts.emit?.("searched", { query: this.query, results: this.flat.length });
664
+ this.#opts.emit?.("searched", { query: this.#query, results: this.#flat.length });
665
665
  }
666
666
  }
667
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();
668
+ #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
674
  }
675
675
 
676
676
  /**
@@ -679,16 +679,16 @@ export class ContextMentionController {
679
679
  * there). In picker mode the search field owns focus + `aria-activedescendant`,
680
680
  * so the textarea's is cleared.
681
681
  */
682
- private syncActiveDescendant(): void {
682
+ #syncActiveDescendant(): void {
683
683
  const live =
684
- this.isOpenState &&
685
- !this.pickerMode &&
686
- this.activeIndex >= 0 &&
687
- this.activeIndex < this.flat.length;
684
+ this.#isOpenState &&
685
+ !this.#pickerMode &&
686
+ this.#activeIndex >= 0 &&
687
+ this.#activeIndex < this.#flat.length;
688
688
  if (live) {
689
689
  this.input.element.setAttribute(
690
690
  "aria-activedescendant",
691
- `${this.listboxId}-opt-${this.activeIndex}`
691
+ `${this.#listboxId}-opt-${this.#activeIndex}`
692
692
  );
693
693
  } else {
694
694
  this.input.element.removeAttribute("aria-activedescendant");
@@ -696,56 +696,56 @@ export class ContextMentionController {
696
696
  }
697
697
 
698
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);
699
+ #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
705
  // Reflect a synchronous result immediately (async results render on arrival).
706
- this.render();
706
+ this.#render();
707
707
  }
708
708
 
709
709
  /** Returns true when the key was consumed (caller must not also handle it). */
710
710
  handleKeydown(event: KeyboardEvent): boolean {
711
- if (!this.isOpenState) return false;
711
+ if (!this.#isOpenState) return false;
712
712
 
713
713
  switch (event.key) {
714
714
  case "ArrowDown": {
715
- if (this.flat.length === 0) return true;
715
+ if (this.#flat.length === 0) return true;
716
716
  event.preventDefault();
717
- this.setActiveIndex((this.activeIndex + 1) % this.flat.length);
717
+ this.#setActiveIndex((this.#activeIndex + 1) % this.#flat.length);
718
718
  return true;
719
719
  }
720
720
  case "ArrowUp": {
721
- if (this.flat.length === 0) return true;
721
+ if (this.#flat.length === 0) return true;
722
722
  event.preventDefault();
723
- this.setActiveIndex(
724
- (this.activeIndex - 1 + this.flat.length) % this.flat.length
723
+ this.#setActiveIndex(
724
+ (this.#activeIndex - 1 + this.#flat.length) % this.#flat.length
725
725
  );
726
726
  return true;
727
727
  }
728
728
  case "Home": {
729
- if (this.flat.length === 0) return true;
729
+ if (this.#flat.length === 0) return true;
730
730
  event.preventDefault();
731
- this.setActiveIndex(0);
731
+ this.#setActiveIndex(0);
732
732
  return true;
733
733
  }
734
734
  case "End": {
735
- if (this.flat.length === 0) return true;
735
+ if (this.#flat.length === 0) return true;
736
736
  event.preventDefault();
737
- this.setActiveIndex(this.flat.length - 1);
737
+ this.#setActiveIndex(this.#flat.length - 1);
738
738
  return true;
739
739
  }
740
740
  case "Enter":
741
741
  case "Tab": {
742
- if (this.flat.length === 0) {
742
+ if (this.#flat.length === 0) {
743
743
  // Nothing to pick — close and let Enter submit / Tab move on.
744
744
  this.close();
745
745
  return false;
746
746
  }
747
747
  event.preventDefault();
748
- this.selectIndex(this.activeIndex);
748
+ this.#selectIndex(this.#activeIndex);
749
749
  return true;
750
750
  }
751
751
  case "Escape": {
@@ -757,7 +757,7 @@ export class ContextMentionController {
757
757
  case "Backspace": {
758
758
  // Deleting back through the trigger closes the menu; let the keystroke
759
759
  // proceed so the trigger char itself is removed.
760
- if (this.query.length === 0) this.close();
760
+ if (this.#query.length === 0) this.close();
761
761
  return false;
762
762
  }
763
763
  default:
@@ -765,10 +765,10 @@ export class ContextMentionController {
765
765
  }
766
766
  }
767
767
 
768
- private selectIndex(index: number): void {
769
- const entry = this.flat[index];
768
+ #selectIndex(index: number): void {
769
+ const entry = this.#flat[index];
770
770
  if (!entry) return;
771
- this.commit(entry.source, entry.item);
771
+ this.#commit(entry.source, entry.item);
772
772
  }
773
773
 
774
774
  /**
@@ -777,7 +777,7 @@ export class ContextMentionController {
777
777
  * token (via the same {@link splitCommandQuery}), so the item stays selectable
778
778
  * while the user types args.
779
779
  */
780
- private deriveArgs(query: string): string {
780
+ #deriveArgs(query: string): string {
781
781
  return splitCommandQuery(query).args;
782
782
  }
783
783
 
@@ -787,11 +787,11 @@ export class ContextMentionController {
787
787
  * arg placeholder. Selecting these fills `/name ` into the composer for inline
788
788
  * arg entry instead of dispatching now; execution happens at submit.
789
789
  */
790
- private isInlineCommand(item: AgentWidgetContextMentionItem): boolean {
790
+ #isInlineCommand(item: AgentWidgetContextMentionItem): boolean {
791
791
  return item.command === "server" || item.commandArgsPlaceholder != null;
792
792
  }
793
793
 
794
- private commit(
794
+ #commit(
795
795
  source: AgentWidgetContextMentionSource,
796
796
  item: AgentWidgetContextMentionItem
797
797
  ): void {
@@ -800,18 +800,18 @@ export class ContextMentionController {
800
800
  // Inline completion (Slack-style): fill `/name ` and let the user type the
801
801
  // argument. No chip, no dispatch now — `dispatchInlineCommand` runs it at
802
802
  // submit with the typed args.
803
- if (this.isInlineCommand(item)) {
804
- this.completeCommandInline(source, item);
803
+ if (this.#isInlineCommand(item)) {
804
+ this.#completeCommandInline(source, item);
805
805
  return;
806
806
  }
807
807
 
808
808
  // (a) client-action: run and short-circuit. No chip, no send.
809
809
  if (kind === "action") {
810
- const args = this.deriveArgs(this.query);
811
- this.stripQuery();
810
+ const args = this.#deriveArgs(this.#query);
811
+ this.#stripQuery();
812
812
  this.close();
813
- this.runAction(item, args);
814
- this.opts.emit?.("command", {
813
+ this.#runAction(item, args);
814
+ this.#opts.emit?.("command", {
815
815
  sourceId: source.id,
816
816
  itemId: item.id,
817
817
  kind: "action",
@@ -823,11 +823,11 @@ export class ContextMentionController {
823
823
 
824
824
  // (b) prompt-macro: write resolved text into the composer, optional submit.
825
825
  if (kind === "prompt") {
826
- const args = this.deriveArgs(this.query);
827
- const target = this.captureStripTarget();
826
+ const args = this.#deriveArgs(this.#query);
827
+ const target = this.#captureStripTarget();
828
828
  this.close();
829
- void this.runPromptMacro(source, item, args, target);
830
- this.opts.emit?.("command", {
829
+ void this.#runPromptMacro(source, item, args, target);
830
+ this.#opts.emit?.("command", {
831
831
  sourceId: source.id,
832
832
  itemId: item.id,
833
833
  kind: "prompt",
@@ -840,15 +840,15 @@ export class ContextMentionController {
840
840
  // and track its resolve by composer id. Chip display: go through the manager
841
841
  // (chip row + resolve). Server commands never reach here — they inline-complete
842
842
  // above.
843
- if (this.isInlineDisplay()) {
844
- this.commitInlineMention(source, item);
843
+ if (this.#isInlineDisplay()) {
844
+ this.#commitInlineMention(source, item);
845
845
  return;
846
846
  }
847
847
 
848
- const ok = this.opts.onSelect(source, item, "");
848
+ const ok = this.#opts.onSelect(source, item, "");
849
849
  if (ok) {
850
- this.stripQuery();
851
- this.opts.emit?.("selected", {
850
+ this.#stripQuery();
851
+ this.#opts.emit?.("selected", {
852
852
  sourceId: source.id,
853
853
  itemId: item.id,
854
854
  label: item.label,
@@ -859,10 +859,10 @@ export class ContextMentionController {
859
859
  }
860
860
 
861
861
  /** True when `@` mentions render as inline tokens (contenteditable composer). */
862
- private isInlineDisplay(): boolean {
862
+ #isInlineDisplay(): boolean {
863
863
  return (
864
- this.opts.mentionConfig.display === "inline" &&
865
- !!this.opts.onInsertMention &&
864
+ this.#opts.mentionConfig.display === "inline" &&
865
+ !!this.#opts.onInsertMention &&
866
866
  !!this.input.insertMentionAtTrigger
867
867
  );
868
868
  }
@@ -873,24 +873,24 @@ export class ContextMentionController {
873
873
  * keyed by the returned composer id. The limit is gated BEFORE insertion so a
874
874
  * rejected pick never leaves a stray token.
875
875
  */
876
- private commitInlineMention(
876
+ #commitInlineMention(
877
877
  source: AgentWidgetContextMentionSource,
878
878
  item: AgentWidgetContextMentionItem
879
879
  ): void {
880
880
  // Limit gate BEFORE insertion (fires the manager's rejection hooks) so a
881
881
  // rejected pick never leaves a stray token.
882
- if (this.opts.admitMention && !this.opts.admitMention(source, item)) {
882
+ if (this.#opts.admitMention && !this.#opts.admitMention(source, item)) {
883
883
  this.close();
884
884
  this.input.focus();
885
885
  return;
886
886
  }
887
887
  const ref = refFromItem(source, item);
888
- const id = this.triggerMatch
889
- ? this.input.insertMentionAtTrigger!(ref, this.triggerMatch)
888
+ const id = this.#triggerMatch
889
+ ? this.input.insertMentionAtTrigger!(ref, this.#triggerMatch)
890
890
  : (this.input.insertMentionAtSelection?.(ref) ?? null);
891
891
  if (id) {
892
- this.opts.onInsertMention!(id, source, item, "");
893
- this.opts.emit?.("selected", {
892
+ this.#opts.onInsertMention!(id, source, item, "");
893
+ this.#opts.emit?.("selected", {
894
894
  sourceId: source.id,
895
895
  itemId: item.id,
896
896
  label: item.label,
@@ -900,15 +900,15 @@ export class ContextMentionController {
900
900
  // change between parse and commit (an IME/edit race). Fire the same
901
901
  // rejection path chip-mode failures use so hosts/tests can react rather than
902
902
  // the typed `@query` being silently stranded.
903
- this.opts.mentionConfig.onMentionRejected?.(item, "stale");
904
- this.opts.emit?.("rejected", {
903
+ this.#opts.mentionConfig.onMentionRejected?.(item, "stale");
904
+ this.#opts.emit?.("rejected", {
905
905
  sourceId: source.id,
906
906
  itemId: item.id,
907
907
  reason: "stale",
908
908
  });
909
909
  }
910
910
  // The `@query` range became the token (typed path); nothing left to strip.
911
- this.triggerMatch = null;
911
+ this.#triggerMatch = null;
912
912
  this.close();
913
913
  this.input.focus();
914
914
  }
@@ -920,18 +920,18 @@ export class ContextMentionController {
920
920
  * submit with the typed args. The trailing space + `isInlineArgTail` keep the
921
921
  * menu from reopening while the argument is typed.
922
922
  */
923
- private completeCommandInline(
923
+ #completeCommandInline(
924
924
  source: AgentWidgetContextMentionSource,
925
925
  item: AgentWidgetContextMentionItem
926
926
  ): void {
927
- const insert = `${this.activeChannel.trigger}${item.label} `;
927
+ const insert = `${this.#activeChannel.trigger}${item.label} `;
928
928
  const caret = this.input.getSelection().start;
929
929
  // Typed path: replace the `<trigger><query>` span. Picker path (button open,
930
930
  // no trigger char present): insert at the caret. Line-start commands only
931
931
  // dispatch when they lead a line, so a mid-line picker insert degrades to
932
932
  // literal text — acceptable for the rarely-used slash button. Both endpoints
933
933
  // are LOGICAL offsets, so the edit must run in logical space (see below).
934
- const start = this.triggerMatch ? this.triggerMatch.triggerIndex : caret;
934
+ const start = this.#triggerMatch ? this.#triggerMatch.triggerIndex : caret;
935
935
  if (this.input.replaceLogicalRange) {
936
936
  // Inline: splice at the document level so tokens elsewhere in the line
937
937
  // survive (a display-string slice would misalign against `@Label` and
@@ -945,9 +945,9 @@ export class ContextMentionController {
945
945
  start + insert.length
946
946
  );
947
947
  }
948
- this.triggerMatch = null;
948
+ this.#triggerMatch = null;
949
949
  this.close();
950
- this.opts.emit?.("command", {
950
+ this.#opts.emit?.("command", {
951
951
  sourceId: source.id,
952
952
  itemId: item.id,
953
953
  kind: item.command ?? "prompt",
@@ -963,7 +963,7 @@ export class ContextMentionController {
963
963
  * followed by argument text (`"lookup "`, `"lookup 1042"`). Selecting is done;
964
964
  * the menu closes so the list doesn't reappear while the argument is typed.
965
965
  */
966
- private isInlineArgTail(
966
+ #isInlineArgTail(
967
967
  channel: NormalizedMentionChannel,
968
968
  query: string
969
969
  ): boolean {
@@ -971,7 +971,7 @@ export class ContextMentionController {
971
971
  if (!name || query.length <= name.length) return false;
972
972
  return channel.sources.some((s) => {
973
973
  const item = s.matchCommand?.(name);
974
- return !!item && this.isInlineCommand(item);
974
+ return !!item && this.#isInlineCommand(item);
975
975
  });
976
976
  }
977
977
 
@@ -982,12 +982,12 @@ export class ContextMentionController {
982
982
  * commands match. Returns the source, item, and the argument text after the
983
983
  * name. Handles menu-selected and hand-typed commands uniformly.
984
984
  */
985
- private matchInlineCommand(text: string): {
985
+ #matchInlineCommand(text: string): {
986
986
  source: AgentWidgetContextMentionSource;
987
987
  item: AgentWidgetContextMentionItem;
988
988
  args: string;
989
989
  } | null {
990
- for (const channel of this.channels) {
990
+ for (const channel of this.#channels) {
991
991
  if (!channel.trigger) continue;
992
992
  // Match the live trigger parser: a line-start command may lead ANY line
993
993
  // (not just the first); input-start only the very first; anywhere the whole
@@ -1004,7 +1004,7 @@ export class ContextMentionController {
1004
1004
  if (!name) continue;
1005
1005
  for (const source of channel.sources) {
1006
1006
  const item = source.matchCommand?.(name);
1007
- if (item && this.isInlineCommand(item)) return { source, item, args };
1007
+ if (item && this.#isInlineCommand(item)) return { source, item, args };
1008
1008
  }
1009
1009
  }
1010
1010
  }
@@ -1018,11 +1018,11 @@ export class ContextMentionController {
1018
1018
  * resolved `context`. Returns null when `text` isn't an inline command.
1019
1019
  */
1020
1020
  async dispatchInlineCommand(text: string): Promise<InlineCommandResult | null> {
1021
- const match = this.matchInlineCommand(text);
1021
+ const match = this.#matchInlineCommand(text);
1022
1022
  if (!match) return null;
1023
1023
  const { source, item, args } = match;
1024
1024
  const kind = item.command ?? "prompt";
1025
- this.opts.emit?.("command", {
1025
+ this.#opts.emit?.("command", {
1026
1026
  sourceId: source.id,
1027
1027
  itemId: item.id,
1028
1028
  kind,
@@ -1030,14 +1030,14 @@ export class ContextMentionController {
1030
1030
  });
1031
1031
 
1032
1032
  if (kind === "action") {
1033
- this.runAction(item, args);
1033
+ this.#runAction(item, args);
1034
1034
  return { kind: "action" };
1035
1035
  }
1036
1036
 
1037
1037
  if (kind === "prompt") {
1038
1038
  let payload;
1039
1039
  try {
1040
- payload = await source.resolve(item, this.resolveContext(args, text));
1040
+ payload = await source.resolve(item, this.#resolveContext(args, text));
1041
1041
  } catch (error) {
1042
1042
  if (typeof console !== "undefined") {
1043
1043
  console.warn("[Persona] inline prompt command resolve failed", error);
@@ -1049,7 +1049,7 @@ export class ContextMentionController {
1049
1049
 
1050
1050
  // server: resolve at submit; refs empty (no chip), context namespaced.
1051
1051
  const finalize = async (): Promise<MentionSubmitBundle> => {
1052
- const payload = await source.resolve(item, this.resolveContext(args, text));
1052
+ const payload = await source.resolve(item, this.#resolveContext(args, text));
1053
1053
  const context: Record<string, Record<string, unknown>> = {};
1054
1054
  if (payload.context) context[source.id] = { [item.id]: payload.context };
1055
1055
  return {
@@ -1061,10 +1061,10 @@ export class ContextMentionController {
1061
1061
  return { kind: "server", mentions: { refs: [], finalize } };
1062
1062
  }
1063
1063
 
1064
- private resolveContext(args: string, composerText: string) {
1064
+ #resolveContext(args: string, composerText: string) {
1065
1065
  return {
1066
- messages: this.opts.getMessages(),
1067
- config: this.opts.getConfig(),
1066
+ messages: this.#opts.getMessages(),
1067
+ config: this.#opts.getConfig(),
1068
1068
  composerText,
1069
1069
  args,
1070
1070
  signal: new AbortController().signal,
@@ -1072,14 +1072,14 @@ export class ContextMentionController {
1072
1072
  }
1073
1073
 
1074
1074
  /** Run a `command:"action"` handler, guarding against throws. */
1075
- private runAction(item: AgentWidgetContextMentionItem, args: string): void {
1075
+ #runAction(item: AgentWidgetContextMentionItem, args: string): void {
1076
1076
  if (!item.action) return;
1077
1077
  try {
1078
1078
  void Promise.resolve(
1079
1079
  item.action({
1080
1080
  args,
1081
- config: this.opts.getConfig(),
1082
- messages: this.opts.getMessages(),
1081
+ config: this.#opts.getConfig(),
1082
+ messages: this.#opts.getMessages(),
1083
1083
  composer: this.input,
1084
1084
  })
1085
1085
  ).catch((error) => {
@@ -1099,21 +1099,21 @@ export class ContextMentionController {
1099
1099
  * range `[triggerIndex, caret)` to rewrite, plus the display `value` at select
1100
1100
  * time for the textarea fallback (where DISPLAY === LOGICAL).
1101
1101
  */
1102
- private captureStripTarget(): {
1102
+ #captureStripTarget(): {
1103
1103
  value: string;
1104
1104
  start: number;
1105
1105
  end: number;
1106
1106
  } | null {
1107
- if (!this.triggerMatch) return null;
1107
+ if (!this.#triggerMatch) return null;
1108
1108
  return {
1109
1109
  value: this.input.getValue(),
1110
- start: this.triggerMatch.triggerIndex,
1110
+ start: this.#triggerMatch.triggerIndex,
1111
1111
  end: this.input.getSelection().start,
1112
1112
  };
1113
1113
  }
1114
1114
 
1115
1115
  /** Resolve a `command:"prompt"` macro and write its text into the composer. */
1116
- private async runPromptMacro(
1116
+ async #runPromptMacro(
1117
1117
  source: AgentWidgetContextMentionSource,
1118
1118
  item: AgentWidgetContextMentionItem,
1119
1119
  args: string,
@@ -1123,8 +1123,8 @@ export class ContextMentionController {
1123
1123
  let payload;
1124
1124
  try {
1125
1125
  payload = await source.resolve(item, {
1126
- messages: this.opts.getMessages(),
1127
- config: this.opts.getConfig(),
1126
+ messages: this.#opts.getMessages(),
1127
+ config: this.#opts.getConfig(),
1128
1128
  composerText: composer.getValue(),
1129
1129
  args,
1130
1130
  signal: new AbortController().signal,
@@ -1154,30 +1154,30 @@ export class ContextMentionController {
1154
1154
  if (item.submitOnSelect) composer.submit();
1155
1155
  }
1156
1156
 
1157
- private stripQuery(): void {
1158
- if (!this.triggerMatch) return;
1157
+ #stripQuery(): void {
1158
+ if (!this.#triggerMatch) return;
1159
1159
  const caret = this.input.getSelection().start;
1160
1160
  if (this.input.replaceLogicalRange) {
1161
1161
  // Inline: remove the `@query` span in logical space, preserving every token
1162
1162
  // outside `[triggerIndex, caret)`. A display-string strip would misalign and
1163
1163
  // a full re-render would destroy the tokens.
1164
- this.input.replaceLogicalRange(this.triggerMatch.triggerIndex, caret, "");
1164
+ this.input.replaceLogicalRange(this.#triggerMatch.triggerIndex, caret, "");
1165
1165
  } else {
1166
1166
  // Textarea: DISPLAY === LOGICAL, so the string strip is exact.
1167
- const out = stripMentionQuery(this.input.getValue(), this.triggerMatch, caret);
1167
+ const out = stripMentionQuery(this.input.getValue(), this.#triggerMatch, caret);
1168
1168
  this.input.setValueWithCaret(out.value, out.caret);
1169
1169
  }
1170
1170
  // Auto-resize listener reacts to input; our re-parse yields null → stays closed.
1171
1171
  this.input.dispatchInput();
1172
- this.triggerMatch = null;
1172
+ this.#triggerMatch = null;
1173
1173
  }
1174
1174
 
1175
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();
1176
+ this.#disconnectComposerResize();
1177
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
1178
+ this.#searchAbort?.abort();
1179
+ this.#popover?.destroy();
1180
+ this.#menu.destroy();
1181
1181
  const el = this.input.element;
1182
1182
  el.removeAttribute("aria-haspopup");
1183
1183
  el.removeAttribute("aria-controls");