@runtypelabs/persona 4.0.0 → 4.2.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.
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Built-in default approval renderer.
3
+ *
4
+ * Renders the neutral "permission card": a tool icon, a "The assistant wants to
5
+ * use <tool>" title, the call arguments collapsed behind a "show more" header
6
+ * chevron, and an action row. By default the row is a single "Allow" (allow
7
+ * once) + "Deny". When `config.approval.enableAlwaysAllow` is true it becomes a
8
+ * split "Always allow ⏎" primary with an "Allow once ⌘⏎" dropdown plus
9
+ * "Deny Esc", with keyboard shortcuts — that affordance is opt-in because
10
+ * "Always allow" only means anything if the integrator persists the policy via
11
+ * `onDecision`'s `remember` flag (needs a backend).
12
+ *
13
+ * Installed as an internal `renderApproval` plugin (see ui.ts) so it rides the
14
+ * existing plugin stub-and-hydrate + teardown path and survives idiomorph
15
+ * re-renders. A user-supplied `renderApproval` plugin still fully overrides it.
16
+ *
17
+ * Resolved states mirror the example plugin: approved → render nothing (the
18
+ * tool call takes over the transcript); denied/timeout → a subtle one-line
19
+ * trace.
20
+ */
21
+ import { createElement } from "../utils/dom";
22
+ import { renderLucideIcon } from "../utils/icons";
23
+ import { formatUnknownValue } from "../utils/formatting";
24
+ import { WEBMCP_TOOL_PREFIX, getWebMcpToolDisplayTitle } from "../webmcp-bridge";
25
+ import { createPopover, isEditableEventTarget, type PopoverHandle } from "../plugin-kit";
26
+ import type {
27
+ AgentWidgetMessage,
28
+ AgentWidgetConfig,
29
+ AgentWidgetApprovalConfig,
30
+ } from "../types";
31
+ import type { AgentWidgetPlugin } from "../plugins/types";
32
+ import { humanizeToolName, approvalDetailsExpansionState } from "./approval-bubble";
33
+
34
+ type Approval = NonNullable<AgentWidgetMessage["approval"]>;
35
+ type Decide = (options?: { remember?: boolean }) => void;
36
+
37
+ /**
38
+ * Per-widget-instance runtime state. The document `keydown` handler (re-bound on
39
+ * each render to the freshest approve/deny closures) and the "Allow once"
40
+ * popover are torn down when the approval resolves, the bubble rebuilds, or the
41
+ * widget is destroyed. This lives on the plugin instance (NOT module scope) so
42
+ * tearing down one widget never clears listeners/popovers another widget on the
43
+ * same page still owns.
44
+ */
45
+ interface InstanceState {
46
+ keyHandlers: Map<string, (e: KeyboardEvent) => void>;
47
+ popovers: Map<string, PopoverHandle>;
48
+ // Order in which approvals FIRST became pending — not re-render order. The
49
+ // newest pending approval (bottom of the thread) owns the keyboard shortcuts,
50
+ // so Enter/Esc don't fire on every pending card at once, and an older card
51
+ // re-rendering can't steal ownership from a newer one.
52
+ pendingOrder: string[];
53
+ latestPendingApprovalId: string | null;
54
+ }
55
+
56
+ const createInstanceState = (): InstanceState => ({
57
+ keyHandlers: new Map(),
58
+ popovers: new Map(),
59
+ pendingOrder: [],
60
+ latestPendingApprovalId: null,
61
+ });
62
+
63
+ // Drop a message's transient listener + popover WITHOUT touching its place in
64
+ // the pending order. Used when a still-pending card re-renders and rebinds fresh
65
+ // closures (so a rebuild of an older card doesn't reorder it to "newest").
66
+ const detachMessage = (state: InstanceState, messageId: string): void => {
67
+ const prevKey = state.keyHandlers.get(messageId);
68
+ if (prevKey) {
69
+ document.removeEventListener("keydown", prevKey);
70
+ state.keyHandlers.delete(messageId);
71
+ }
72
+ const popover = state.popovers.get(messageId);
73
+ if (popover) {
74
+ popover.destroy();
75
+ state.popovers.delete(messageId);
76
+ }
77
+ };
78
+
79
+ // Fully retire a message (resolved, denied, or widget destroyed): detach its
80
+ // listener/popover AND remove it from the pending order. If the keyboard owner
81
+ // just went away, promote the newest remaining pending approval so Enter/Esc
82
+ // keep working instead of going dead while another pending card remains.
83
+ const teardownMessage = (state: InstanceState, messageId: string): void => {
84
+ detachMessage(state, messageId);
85
+ const idx = state.pendingOrder.indexOf(messageId);
86
+ if (idx !== -1) state.pendingOrder.splice(idx, 1);
87
+ if (state.latestPendingApprovalId === messageId) {
88
+ state.latestPendingApprovalId = state.pendingOrder.length
89
+ ? state.pendingOrder[state.pendingOrder.length - 1]
90
+ : null;
91
+ }
92
+ };
93
+
94
+ const resolveApprovalConfig = (
95
+ config?: AgentWidgetConfig
96
+ ): AgentWidgetApprovalConfig | undefined =>
97
+ config?.approval !== false ? config?.approval : undefined;
98
+
99
+ const isDetailsExpanded = (
100
+ messageId: string,
101
+ approvalConfig?: AgentWidgetApprovalConfig
102
+ ): boolean => {
103
+ const mode = approvalConfig?.detailsDisplay ?? "collapsed";
104
+ return approvalDetailsExpansionState.get(messageId) ?? mode === "expanded";
105
+ };
106
+
107
+ const kbd = (label: string): HTMLElement => {
108
+ const el = createElement("span", "persona-approval-kbd");
109
+ el.textContent = label;
110
+ return el;
111
+ };
112
+
113
+ // Title: "The assistant wants to use <tool>" with an optional "from <source>"
114
+ // when a non-WebMCP `toolType` source label is present. `formatDescription`
115
+ // fully overrides the line. Mirrors the built-in bubble's label priority
116
+ // (formatDescription → declared WebMCP title → humanized tool name).
117
+ const buildTitle = (
118
+ approval: Approval,
119
+ approvalConfig?: AgentWidgetApprovalConfig
120
+ ): HTMLElement => {
121
+ const title = createElement("span", "persona-approval-title");
122
+ if (approvalConfig?.titleColor) title.style.color = approvalConfig.titleColor;
123
+
124
+ const isWebMcp =
125
+ approval.toolType === "webmcp" || approval.toolName.startsWith(WEBMCP_TOOL_PREFIX);
126
+ const declaredTitle = isWebMcp
127
+ ? getWebMcpToolDisplayTitle(approval.toolName)
128
+ : undefined;
129
+
130
+ const custom = approvalConfig?.formatDescription?.({
131
+ toolName: approval.toolName,
132
+ toolType: approval.toolType,
133
+ description: approval.description ?? "",
134
+ parameters: approval.parameters,
135
+ ...(declaredTitle ? { displayTitle: declaredTitle } : {}),
136
+ ...(approval.reason ? { reason: approval.reason } : {}),
137
+ });
138
+ if (custom) {
139
+ title.textContent = custom;
140
+ return title;
141
+ }
142
+
143
+ const toolDisplay = declaredTitle ?? humanizeToolName(approval.toolName);
144
+ const source =
145
+ approval.toolType && approval.toolType !== "webmcp" ? approval.toolType : null;
146
+ title.append("The assistant wants to use ");
147
+ const toolStrong = document.createElement("strong");
148
+ toolStrong.textContent = toolDisplay;
149
+ title.appendChild(toolStrong);
150
+ if (source) {
151
+ title.append(" from ");
152
+ const srcStrong = document.createElement("strong");
153
+ srcStrong.textContent = source;
154
+ title.appendChild(srcStrong);
155
+ }
156
+ return title;
157
+ };
158
+
159
+ const buildResolvedTrace = (approval: Approval): HTMLElement => {
160
+ const row = createElement("div", "persona-approval-resolved");
161
+ const icon = renderLucideIcon("ban", 15, "currentColor", 2);
162
+ if (icon) row.appendChild(icon);
163
+ const name = createElement("span", "persona-approval-resolved-name");
164
+ name.textContent = approval.toolName ? humanizeToolName(approval.toolName) : "Tool";
165
+ row.append(name, document.createTextNode(approval.status === "timeout" ? " timed out" : " denied"));
166
+ return row;
167
+ };
168
+
169
+ const buildPending = (
170
+ state: InstanceState,
171
+ message: AgentWidgetMessage,
172
+ approval: Approval,
173
+ approvalConfig: AgentWidgetApprovalConfig | undefined,
174
+ approve: Decide,
175
+ deny: Decide,
176
+ enableAlways: boolean
177
+ ): HTMLElement => {
178
+ const card = createElement("div", "persona-approval-card persona-shadow-sm");
179
+ card.id = `bubble-${message.id}`;
180
+ card.setAttribute("data-message-id", message.id);
181
+ card.setAttribute("data-bubble-type", "approval");
182
+ if (approvalConfig?.backgroundColor) card.style.background = approvalConfig.backgroundColor;
183
+ if (approvalConfig?.borderColor) card.style.borderColor = approvalConfig.borderColor;
184
+ if (approvalConfig?.shadow !== undefined) {
185
+ card.style.boxShadow = approvalConfig.shadow.trim() === "" ? "none" : approvalConfig.shadow;
186
+ }
187
+
188
+ const detailsMode = approvalConfig?.detailsDisplay ?? "collapsed";
189
+ // The disclosure surfaces the agent-facing description (prompt prose, usage
190
+ // rules) and the raw call parameters. `buildTitle` never falls back to the
191
+ // raw description (it uses formatDescription → declared title → humanized
192
+ // name), so the description is only ever visible here. Mirrors the legacy
193
+ // bubble, which also opened a disclosure when only a description was present.
194
+ const hasDescription = Boolean(approval.description) && detailsMode !== "hidden";
195
+ const hasParams = approval.parameters != null && detailsMode !== "hidden";
196
+ const hasDetails = hasDescription || hasParams;
197
+ const expanded = hasDetails && isDetailsExpanded(message.id, approvalConfig);
198
+
199
+ // The card uses the whole header as the disclosure toggle (chevron-only, no
200
+ // separate text button), but the legacy `showDetailsLabel`/`hideDetailsLabel`
201
+ // strings are still honored as the toggle's accessible label so integrators
202
+ // who localized them keep a meaningful, customizable name on the control.
203
+ const showDetailsLabel = approvalConfig?.showDetailsLabel ?? "Show details";
204
+ const hideDetailsLabel = approvalConfig?.hideDetailsLabel ?? "Hide details";
205
+
206
+ // Header. When a disclosure exists, the whole header toggles its visibility.
207
+ const head = createElement("button", "persona-approval-head") as HTMLButtonElement;
208
+ head.type = "button";
209
+ if (hasDetails) {
210
+ head.setAttribute("data-action", "toggle-params");
211
+ head.setAttribute("aria-expanded", expanded ? "true" : "false");
212
+ head.setAttribute("aria-label", expanded ? hideDetailsLabel : showDetailsLabel);
213
+ } else {
214
+ head.setAttribute("data-static", "true");
215
+ }
216
+
217
+ const logo = createElement("span", "persona-approval-logo");
218
+ const glyph = renderLucideIcon("shield-check", 16, "currentColor", 2);
219
+ if (glyph) logo.appendChild(glyph);
220
+ head.appendChild(logo);
221
+
222
+ const title = buildTitle(approval, approvalConfig);
223
+ if (hasDetails) {
224
+ const toggle = createElement("span", "persona-approval-toggle");
225
+ toggle.setAttribute("aria-hidden", "true");
226
+ const chevron = renderLucideIcon("chevron-down", 14, "currentColor", 2);
227
+ if (chevron) toggle.appendChild(chevron);
228
+ title.append(" ");
229
+ title.appendChild(toggle);
230
+ }
231
+ head.appendChild(title);
232
+ card.appendChild(head);
233
+
234
+ const body = createElement("div", "persona-approval-body");
235
+
236
+ if (hasDetails) {
237
+ const details = createElement("div", "persona-approval-details");
238
+ details.setAttribute("data-role", "params");
239
+ details.hidden = !expanded;
240
+
241
+ if (hasDescription) {
242
+ const desc = createElement("p", "persona-approval-desc");
243
+ if (approvalConfig?.descriptionColor) desc.style.color = approvalConfig.descriptionColor;
244
+ desc.textContent = approval.description as string;
245
+ details.appendChild(desc);
246
+ }
247
+
248
+ if (hasParams) {
249
+ const pre = createElement("pre", "persona-approval-params");
250
+ if (approvalConfig?.parameterBackgroundColor) pre.style.background = approvalConfig.parameterBackgroundColor;
251
+ if (approvalConfig?.parameterTextColor) pre.style.color = approvalConfig.parameterTextColor;
252
+ pre.textContent = formatUnknownValue(approval.parameters);
253
+ details.appendChild(pre);
254
+ }
255
+
256
+ body.appendChild(details);
257
+ }
258
+
259
+ // Agent-authored justification: attacker-writable, so plain text + attributed.
260
+ if (approval.reason) {
261
+ const reasonLine = createElement("p", "persona-approval-reason");
262
+ if (approvalConfig?.reasonColor) reasonLine.style.color = approvalConfig.reasonColor;
263
+ else if (approvalConfig?.descriptionColor) reasonLine.style.color = approvalConfig.descriptionColor;
264
+ const label = createElement("span", "persona-approval-reason-label");
265
+ label.textContent = `${approvalConfig?.reasonLabel ?? "Agent's stated reason:"} `;
266
+ reasonLine.append(label, document.createTextNode(approval.reason));
267
+ body.appendChild(reasonLine);
268
+ }
269
+
270
+ const actions = createElement("div", "persona-approval-actions");
271
+ let popover: PopoverHandle | null = null;
272
+
273
+ const applyPrimaryColors = (el: HTMLElement): void => {
274
+ if (approvalConfig?.approveButtonColor) el.style.background = approvalConfig.approveButtonColor;
275
+ if (approvalConfig?.approveButtonTextColor) el.style.color = approvalConfig.approveButtonTextColor;
276
+ };
277
+ const denyBtn = createElement("button", "persona-approval-deny") as HTMLButtonElement;
278
+ denyBtn.type = "button";
279
+ denyBtn.setAttribute("data-action", "deny");
280
+ if (approvalConfig?.denyButtonColor) denyBtn.style.background = approvalConfig.denyButtonColor;
281
+ if (approvalConfig?.denyButtonTextColor) denyBtn.style.color = approvalConfig.denyButtonTextColor;
282
+ denyBtn.append(approvalConfig?.denyLabel ?? "Deny");
283
+
284
+ if (enableAlways) {
285
+ const split = createElement("div", "persona-approval-split");
286
+ const primary = createElement("button", "persona-approval-primary") as HTMLButtonElement;
287
+ primary.type = "button";
288
+ primary.setAttribute("data-action", "always");
289
+ applyPrimaryColors(primary);
290
+ primary.append(approvalConfig?.approveLabel ?? "Always allow", kbd("⏎"));
291
+
292
+ const caret = createElement("button", "persona-approval-caret") as HTMLButtonElement;
293
+ caret.type = "button";
294
+ caret.setAttribute("data-action", "toggle-menu");
295
+ caret.setAttribute("aria-label", "More options");
296
+ applyPrimaryColors(caret);
297
+ const caretIcon = renderLucideIcon("chevron-down", 15, "currentColor", 2);
298
+ if (caretIcon) caret.appendChild(caretIcon);
299
+
300
+ split.append(primary, caret);
301
+ actions.append(split, denyBtn);
302
+ denyBtn.append(kbd("Esc"));
303
+
304
+ // "Allow once" menu, portaled out of the transcript by createPopover so it
305
+ // overlays the rest of the UI and isn't clipped by the scroll container.
306
+ const menu = createElement("div", "persona-approval-menu");
307
+ const once = createElement("button", "persona-approval-menu-item") as HTMLButtonElement;
308
+ once.type = "button";
309
+ once.append("Allow once", kbd("⌘⏎"));
310
+ menu.appendChild(once);
311
+ popover = createPopover({
312
+ anchor: split,
313
+ content: menu,
314
+ placement: "bottom-start",
315
+ matchAnchorWidth: true,
316
+ });
317
+ state.popovers.set(message.id, popover);
318
+ once.addEventListener("click", () => {
319
+ teardownMessage(state, message.id);
320
+ approve(); // Allow once
321
+ });
322
+ } else {
323
+ const allow = createElement(
324
+ "button",
325
+ "persona-approval-primary persona-approval-primary--solo"
326
+ ) as HTMLButtonElement;
327
+ allow.type = "button";
328
+ allow.setAttribute("data-action", "allow");
329
+ applyPrimaryColors(allow);
330
+ allow.append(approvalConfig?.approveLabel ?? "Allow");
331
+ actions.append(allow, denyBtn);
332
+ }
333
+
334
+ body.appendChild(actions);
335
+ card.appendChild(body);
336
+
337
+ // Single delegated click listener; survives morph via the plugin hydrate path.
338
+ card.addEventListener("click", (e) => {
339
+ const target = e.target instanceof Element ? e.target.closest("[data-action]") : null;
340
+ if (!target) return;
341
+ const action = target.getAttribute("data-action");
342
+ if (action === "toggle-params") {
343
+ const pre = card.querySelector<HTMLElement>('[data-role="params"]');
344
+ if (pre) {
345
+ const willOpen = pre.hidden;
346
+ pre.hidden = !willOpen;
347
+ head.setAttribute("aria-expanded", willOpen ? "true" : "false");
348
+ head.setAttribute("aria-label", willOpen ? hideDetailsLabel : showDetailsLabel);
349
+ approvalDetailsExpansionState.set(message.id, willOpen);
350
+ }
351
+ return;
352
+ }
353
+ if (action === "toggle-menu") {
354
+ popover?.toggle();
355
+ return;
356
+ }
357
+ if (action === "always") {
358
+ teardownMessage(state, message.id);
359
+ approve({ remember: true });
360
+ return;
361
+ }
362
+ if (action === "allow") {
363
+ teardownMessage(state, message.id);
364
+ approve();
365
+ return;
366
+ }
367
+ if (action === "deny") {
368
+ teardownMessage(state, message.id);
369
+ deny();
370
+ return;
371
+ }
372
+ });
373
+
374
+ return card;
375
+ };
376
+
377
+ /**
378
+ * The built-in approval renderer, shaped as a plugin so ui.ts can run it through
379
+ * the same `renderApproval` pipeline as user plugins (which still take
380
+ * precedence). Reads `config` from the render context, so a single plugin
381
+ * serves every render for one widget instance.
382
+ *
383
+ * Returns the plugin alongside a `teardown` the host pushes into its destroy
384
+ * callbacks — both close over the SAME per-instance state, so destroying one
385
+ * widget never disturbs another widget's open approvals on the same page.
386
+ */
387
+ export const createBuiltInApprovalPlugin = (): {
388
+ plugin: AgentWidgetPlugin;
389
+ teardown: () => void;
390
+ } => {
391
+ const state = createInstanceState();
392
+
393
+ const plugin: AgentWidgetPlugin = {
394
+ id: "persona-built-in-approval",
395
+ renderApproval: ({ message, approve, deny, config }) => {
396
+ const approval = message?.approval;
397
+ if (!approval) return null;
398
+ const approvalConfig = resolveApprovalConfig(config);
399
+
400
+ if (approval.status !== "pending") {
401
+ teardownMessage(state, message.id);
402
+ // Approved → render nothing; the tool call takes over the transcript.
403
+ // (An empty hidden element, not null, suppresses the legacy fallback.)
404
+ if (approval.status === "approved") {
405
+ const hidden = document.createElement("div");
406
+ hidden.style.display = "none";
407
+ return hidden;
408
+ }
409
+ return buildResolvedTrace(approval);
410
+ }
411
+
412
+ // Rebuild: drop any prior listener/popover before (re)binding fresh
413
+ // closures, but KEEP this approval's place in the pending order so a
414
+ // re-render of an older card doesn't reorder it ahead of a newer one.
415
+ detachMessage(state, message.id);
416
+ const enableAlways = approvalConfig?.enableAlwaysAllow === true;
417
+ const card = buildPending(state, message, approval, approvalConfig, approve, deny, enableAlways);
418
+
419
+ if (enableAlways) {
420
+ if (!state.pendingOrder.includes(message.id)) state.pendingOrder.push(message.id);
421
+ // The newest first-seen pending approval owns the shortcuts, regardless
422
+ // of which card happens to be re-rendering right now.
423
+ state.latestPendingApprovalId = state.pendingOrder[state.pendingOrder.length - 1];
424
+ const onKeydown = (e: KeyboardEvent): void => {
425
+ if (isEditableEventTarget(e)) return;
426
+ if (message.id !== state.latestPendingApprovalId) return;
427
+ if (e.key !== "Escape" && e.key !== "Enter") return;
428
+ e.preventDefault();
429
+ // Resolving here promotes the next-newest pending approval to owner.
430
+ // Stop immediate propagation so the SAME keypress doesn't then reach
431
+ // that freshly-promoted card's listener and resolve it too — one
432
+ // keypress resolves exactly one approval; the next owner waits for the
433
+ // next press.
434
+ e.stopImmediatePropagation();
435
+ teardownMessage(state, message.id);
436
+ if (e.key === "Escape") {
437
+ deny();
438
+ } else if (e.metaKey || e.ctrlKey) {
439
+ approve(); // Allow once
440
+ } else {
441
+ approve({ remember: true }); // Always allow
442
+ }
443
+ };
444
+ state.keyHandlers.set(message.id, onKeydown);
445
+ document.addEventListener("keydown", onKeydown);
446
+ }
447
+
448
+ return card;
449
+ },
450
+ };
451
+
452
+ // Release every pending approval's global listener + popover for THIS widget.
453
+ const teardown = (): void => {
454
+ for (const id of [...state.keyHandlers.keys(), ...state.popovers.keys()]) {
455
+ teardownMessage(state, id);
456
+ }
457
+ state.latestPendingApprovalId = null;
458
+ };
459
+
460
+ return { plugin, teardown };
461
+ };
@@ -795,6 +795,13 @@ export const createStandardBubble = (
795
795
  const contentDiv = document.createElement("div");
796
796
  contentDiv.classList.add("persona-message-content");
797
797
 
798
+ // While streaming, lock table column widths (see widget.css) so rows append
799
+ // without per-chunk horizontal reflow. The class is dropped on the final,
800
+ // non-streaming render, relaxing tables to natural content-fit widths.
801
+ if (message.streaming) {
802
+ contentDiv.classList.add("persona-content-streaming");
803
+ }
804
+
798
805
  if (streamAnimationActive && streamPlugin) {
799
806
  if (streamPlugin.containerClass) {
800
807
  contentDiv.classList.add(streamPlugin.containerClass);