@runtypelabs/persona 3.21.2 → 3.22.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 (59) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  5. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/index.cjs +50 -43
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +474 -6
  11. package/dist/index.d.ts +474 -6
  12. package/dist/index.global.js +98 -88
  13. package/dist/index.global.js.map +1 -1
  14. package/dist/index.js +48 -41
  15. package/dist/index.js.map +1 -1
  16. package/dist/smart-dom-reader.cjs +1875 -0
  17. package/dist/smart-dom-reader.d.cts +4521 -0
  18. package/dist/smart-dom-reader.d.ts +4521 -0
  19. package/dist/smart-dom-reader.js +1848 -0
  20. package/dist/theme-editor.cjs +2282 -90
  21. package/dist/theme-editor.d.cts +348 -1
  22. package/dist/theme-editor.d.ts +348 -1
  23. package/dist/theme-editor.js +2267 -90
  24. package/package.json +9 -2
  25. package/src/client.test.ts +165 -0
  26. package/src/client.ts +144 -23
  27. package/src/components/composer-parts.test.ts +34 -0
  28. package/src/components/composer-parts.ts +9 -6
  29. package/src/index.ts +26 -0
  30. package/src/session.test.ts +258 -0
  31. package/src/session.ts +886 -30
  32. package/src/session.webmcp.test.ts +815 -0
  33. package/src/smart-dom-reader.test.ts +135 -0
  34. package/src/smart-dom-reader.ts +135 -0
  35. package/src/theme-editor/color-utils.test.ts +59 -0
  36. package/src/theme-editor/color-utils.ts +38 -2
  37. package/src/theme-editor/index.ts +35 -0
  38. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  39. package/src/theme-editor/webmcp/coerce.ts +286 -0
  40. package/src/theme-editor/webmcp/index.ts +45 -0
  41. package/src/theme-editor/webmcp/summary.ts +324 -0
  42. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  43. package/src/theme-editor/webmcp/tools.ts +795 -0
  44. package/src/theme-editor/webmcp/types.ts +87 -0
  45. package/src/types.ts +186 -0
  46. package/src/ui.composer-keyboard.test.ts +229 -0
  47. package/src/ui.ts +127 -5
  48. package/src/utils/composer-history.test.ts +128 -0
  49. package/src/utils/composer-history.ts +113 -0
  50. package/src/utils/message-fingerprint.test.ts +20 -0
  51. package/src/utils/message-fingerprint.ts +2 -0
  52. package/src/utils/smart-dom-adapter.test.ts +257 -0
  53. package/src/utils/smart-dom-adapter.ts +217 -0
  54. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  55. package/src/vendor/smart-dom-reader/README.md +61 -0
  56. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  57. package/src/vendor/smart-dom-reader/index.js +1618 -0
  58. package/src/webmcp-bridge.test.ts +429 -0
  59. package/src/webmcp-bridge.ts +547 -0
package/src/ui.ts CHANGED
@@ -33,6 +33,11 @@ import { resolveTokenValue } from "./utils/tokens";
33
33
  import { renderLucideIcon } from "./utils/icons";
34
34
  import { createElement, createElementInDocument } from "./utils/dom";
35
35
  import { morphMessages } from "./utils/morph";
36
+ import {
37
+ navigateComposerHistory,
38
+ INITIAL_HISTORY_STATE,
39
+ type ComposerHistoryState
40
+ } from "./utils/composer-history";
36
41
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
37
42
  import {
38
43
  createFollowStateController,
@@ -1398,8 +1403,14 @@ export const createAgentExperience = (
1398
1403
  });
1399
1404
  }
1400
1405
 
1401
- // Resolve the approval
1402
- session.resolveApproval(approvalMessage.approval, decision);
1406
+ // WebMCP gate approvals resolve a local Promise the bridge is parked on
1407
+ // (no server round-trip); server-driven approvals call the API. The
1408
+ // `toolType` marker set in `requestWebMcpApproval` discriminates the two.
1409
+ if (approvalMessage.approval.toolType === "webmcp") {
1410
+ session.resolveWebMcpApproval(messageId, decision);
1411
+ } else {
1412
+ session.resolveApproval(approvalMessage.approval, decision);
1413
+ }
1403
1414
  });
1404
1415
 
1405
1416
  let artifactPaneApi: ArtifactPaneApi | null = null;
@@ -4577,6 +4588,7 @@ export const createAgentExperience = (
4577
4588
 
4578
4589
  textarea.value = "";
4579
4590
  textarea.style.height = "auto"; // Reset height after clearing
4591
+ resetHistoryNavigation();
4580
4592
 
4581
4593
  // Send message with optional content parts
4582
4594
  session.sendMessage(value, { contentParts });
@@ -4587,13 +4599,109 @@ export const createAgentExperience = (
4587
4599
  }
4588
4600
  };
4589
4601
 
4590
- const handleInputEnter = (event: KeyboardEvent) => {
4602
+ // --- Composer message-history navigation (Up/Down arrows) ---
4603
+ // Lets users recall and edit previously sent messages, shell/Slack style.
4604
+ // The pure state machine lives in utils/composer-history.ts; here we feed it
4605
+ // caret info and apply the value it returns. Text-only recall — attachments
4606
+ // on past messages are not restored.
4607
+ const historyNavigationEnabled = () =>
4608
+ config.features?.composerHistory !== false;
4609
+
4610
+ let composerHistoryState: ComposerHistoryState = { ...INITIAL_HISTORY_STATE };
4611
+ // Guards the reset-on-edit listener so our own programmatic value sets (which
4612
+ // dispatch an `input` event for auto-resize) don't exit navigation mode.
4613
+ let suppressHistoryReset = false;
4614
+
4615
+ const resetHistoryNavigation = () => {
4616
+ composerHistoryState = { ...INITIAL_HISTORY_STATE };
4617
+ };
4618
+
4619
+ const getUserMessageHistory = (): string[] =>
4620
+ session
4621
+ .getMessages()
4622
+ .filter((message) => message.role === "user")
4623
+ .map((message) => message.content ?? "")
4624
+ .filter((text) => text.length > 0);
4625
+
4626
+ const applyHistoryValue = (value: string) => {
4627
+ if (!textarea) return;
4628
+ suppressHistoryReset = true;
4629
+ textarea.value = value;
4630
+ // Trigger the auto-resize handler (it listens on `input`).
4631
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
4632
+ suppressHistoryReset = false;
4633
+ // Caret to end for natural editing / appending.
4634
+ const end = textarea.value.length;
4635
+ textarea.setSelectionRange(end, end);
4636
+ };
4637
+
4638
+ const handleComposerInput = () => {
4639
+ // A real edit leaves history-navigation mode.
4640
+ if (suppressHistoryReset) return;
4641
+ resetHistoryNavigation();
4642
+ };
4643
+
4644
+ const handleComposerKeydown = (event: KeyboardEvent) => {
4645
+ if (!textarea) return;
4646
+
4647
+ // Up/Down: walk through previously sent user messages.
4648
+ if (
4649
+ historyNavigationEnabled() &&
4650
+ (event.key === "ArrowUp" || event.key === "ArrowDown") &&
4651
+ !event.shiftKey &&
4652
+ !event.metaKey &&
4653
+ !event.ctrlKey &&
4654
+ !event.altKey &&
4655
+ !event.isComposing
4656
+ ) {
4657
+ const atStart =
4658
+ textarea.selectionStart === 0 && textarea.selectionEnd === 0;
4659
+ const result = navigateComposerHistory({
4660
+ direction: event.key === "ArrowUp" ? "up" : "down",
4661
+ history: getUserMessageHistory(),
4662
+ currentValue: textarea.value,
4663
+ atStart,
4664
+ state: composerHistoryState
4665
+ });
4666
+ composerHistoryState = result.state;
4667
+ if (result.handled) {
4668
+ event.preventDefault();
4669
+ if (result.value !== undefined) {
4670
+ applyHistoryValue(result.value);
4671
+ }
4672
+ return;
4673
+ }
4674
+ // Not handled — fall through to default cursor movement.
4675
+ }
4676
+
4677
+ // Enter: send, unless a response is streaming. While streaming, Enter is
4678
+ // inert (never a stop trigger) — the visible Stop button / Esc stop it.
4591
4679
  if (event.key === "Enter" && !event.shiftKey) {
4680
+ if (session.isStreaming()) {
4681
+ event.preventDefault();
4682
+ return;
4683
+ }
4684
+ resetHistoryNavigation();
4592
4685
  event.preventDefault();
4593
4686
  sendButton.click();
4594
4687
  }
4595
4688
  };
4596
4689
 
4690
+ // Esc-to-stop: while a response streams, Escape within this widget aborts it.
4691
+ // Capture phase + registered at init so it runs before the composer-bar Esc
4692
+ // collapse listener (attached later on open); stopImmediatePropagation keeps
4693
+ // a stream-stop from also collapsing the panel. Scoped via composedPath so a
4694
+ // page-wide Escape elsewhere doesn't hijack.
4695
+ const handleEscStop = (event: KeyboardEvent) => {
4696
+ if (event.key !== "Escape" || event.isComposing) return;
4697
+ if (!session.isStreaming()) return;
4698
+ if (!event.composedPath().includes(container)) return;
4699
+ session.cancel();
4700
+ resetHistoryNavigation();
4701
+ event.preventDefault();
4702
+ event.stopImmediatePropagation();
4703
+ };
4704
+
4597
4705
  const handleInputPaste = async (event: ClipboardEvent) => {
4598
4706
  if (config.attachments?.enabled !== true || !attachmentManager) return;
4599
4707
 
@@ -5465,9 +5573,13 @@ export const createAgentExperience = (
5465
5573
  if (composerForm) {
5466
5574
  composerForm.addEventListener("submit", handleSubmit);
5467
5575
  }
5468
- textarea?.addEventListener("keydown", handleInputEnter);
5576
+ textarea?.addEventListener("keydown", handleComposerKeydown);
5577
+ textarea?.addEventListener("input", handleComposerInput);
5469
5578
  textarea?.addEventListener("paste", handleInputPaste);
5470
5579
 
5580
+ const escStopDoc = mount.ownerDocument ?? document;
5581
+ escStopDoc.addEventListener("keydown", handleEscStop, true);
5582
+
5471
5583
  const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
5472
5584
  let attachmentFileDragDepth = 0;
5473
5585
 
@@ -5544,8 +5656,10 @@ export const createAgentExperience = (
5544
5656
  if (composerForm) {
5545
5657
  composerForm.removeEventListener("submit", handleSubmit);
5546
5658
  }
5547
- textarea?.removeEventListener("keydown", handleInputEnter);
5659
+ textarea?.removeEventListener("keydown", handleComposerKeydown);
5660
+ textarea?.removeEventListener("input", handleComposerInput);
5548
5661
  textarea?.removeEventListener("paste", handleInputPaste);
5662
+ escStopDoc.removeEventListener("keydown", handleEscStop, true);
5549
5663
  });
5550
5664
 
5551
5665
  destroyCallbacks.push(() => {
@@ -7131,6 +7245,14 @@ export const createAgentExperience = (
7131
7245
  if (!approvalMessage?.approval) {
7132
7246
  throw new Error(`Approval not found: ${approvalId}`);
7133
7247
  }
7248
+ // Mirror the in-panel click handler: WebMCP gate bubbles resolve a local
7249
+ // Promise the bridge is parked on (no server round-trip and they carry an
7250
+ // empty executionId/agentId), so they must NOT hit the server approval
7251
+ // API. Route by the `toolType` marker set in `requestWebMcpApproval`.
7252
+ if (approvalMessage.approval.toolType === "webmcp") {
7253
+ session.resolveWebMcpApproval(approvalMessage.id, decision);
7254
+ return;
7255
+ }
7134
7256
  return session.resolveApproval(approvalMessage.approval, decision);
7135
7257
  },
7136
7258
  getMessages() {
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ navigateComposerHistory,
4
+ INITIAL_HISTORY_STATE,
5
+ type ComposerHistoryState
6
+ } from "./composer-history";
7
+
8
+ const history = ["first", "second", "third"]; // oldest -> newest
9
+
10
+ const up = (
11
+ state: ComposerHistoryState,
12
+ overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
13
+ ) =>
14
+ navigateComposerHistory({
15
+ direction: "up",
16
+ history,
17
+ currentValue: "",
18
+ atStart: true,
19
+ state,
20
+ ...overrides
21
+ });
22
+
23
+ const down = (
24
+ state: ComposerHistoryState,
25
+ overrides: Partial<Parameters<typeof navigateComposerHistory>[0]> = {}
26
+ ) =>
27
+ navigateComposerHistory({
28
+ direction: "down",
29
+ history,
30
+ currentValue: "",
31
+ atStart: true,
32
+ state,
33
+ ...overrides
34
+ });
35
+
36
+ describe("navigateComposerHistory", () => {
37
+ it("Up from a fresh composer recalls the newest message and saves the draft", () => {
38
+ const result = up(
39
+ { ...INITIAL_HISTORY_STATE },
40
+ { currentValue: "my draft" }
41
+ );
42
+ expect(result.handled).toBe(true);
43
+ expect(result.value).toBe("third");
44
+ expect(result.state).toEqual({ index: 2, draft: "my draft" });
45
+ });
46
+
47
+ it("repeated Up steps toward older messages", () => {
48
+ let state = up({ ...INITIAL_HISTORY_STATE }).state;
49
+ expect(state.index).toBe(2);
50
+
51
+ const second = up(state);
52
+ expect(second.value).toBe("second");
53
+ state = second.state;
54
+
55
+ const third = up(state);
56
+ expect(third.value).toBe("first");
57
+ state = third.state;
58
+ expect(state.index).toBe(0);
59
+ });
60
+
61
+ it("Up at the oldest entry is consumed but does not change the value", () => {
62
+ const state: ComposerHistoryState = { index: 0, draft: "" };
63
+ const result = up(state);
64
+ expect(result.handled).toBe(true);
65
+ expect(result.value).toBeUndefined();
66
+ expect(result.state.index).toBe(0);
67
+ });
68
+
69
+ it("does not hijack Up when not navigating and the caret is not at the start", () => {
70
+ const result = up({ ...INITIAL_HISTORY_STATE }, { atStart: false });
71
+ expect(result.handled).toBe(false);
72
+ expect(result.value).toBeUndefined();
73
+ });
74
+
75
+ it("continues navigating Up even when the caret is not at the start", () => {
76
+ // Already in history mode (caret sits at end after a recall).
77
+ const state: ComposerHistoryState = { index: 2, draft: "draft" };
78
+ const result = up(state, { atStart: false });
79
+ expect(result.handled).toBe(true);
80
+ expect(result.value).toBe("second");
81
+ });
82
+
83
+ it("Down steps toward newer messages while navigating", () => {
84
+ const state: ComposerHistoryState = { index: 0, draft: "draft" };
85
+ const result = down(state);
86
+ expect(result.handled).toBe(true);
87
+ expect(result.value).toBe("second");
88
+ expect(result.state.index).toBe(1);
89
+ });
90
+
91
+ it("Down past the newest entry restores the saved draft and exits", () => {
92
+ const state: ComposerHistoryState = { index: 2, draft: "saved draft" };
93
+ const result = down(state);
94
+ expect(result.handled).toBe(true);
95
+ expect(result.value).toBe("saved draft");
96
+ expect(result.state).toEqual(INITIAL_HISTORY_STATE);
97
+ });
98
+
99
+ it("Down does nothing when not navigating history", () => {
100
+ const result = down({ ...INITIAL_HISTORY_STATE });
101
+ expect(result.handled).toBe(false);
102
+ expect(result.value).toBeUndefined();
103
+ });
104
+
105
+ it("does nothing when there is no history", () => {
106
+ const result = navigateComposerHistory({
107
+ direction: "up",
108
+ history: [],
109
+ currentValue: "",
110
+ atStart: true,
111
+ state: { ...INITIAL_HISTORY_STATE }
112
+ });
113
+ expect(result.handled).toBe(false);
114
+ });
115
+
116
+ it("round-trips: Up then Down returns to the original draft", () => {
117
+ const draft = "in progress";
118
+ let state = { ...INITIAL_HISTORY_STATE };
119
+
120
+ const u1 = up(state, { currentValue: draft });
121
+ state = u1.state;
122
+ expect(u1.value).toBe("third");
123
+
124
+ const d1 = down(state); // back past newest -> restore draft
125
+ expect(d1.value).toBe(draft);
126
+ expect(d1.state).toEqual(INITIAL_HISTORY_STATE);
127
+ });
128
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure state machine for composer message-history navigation (Up/Down arrows).
3
+ *
4
+ * Mirrors the shell / Slack convention: pressing Up recalls previously sent
5
+ * user messages for re-entry or editing; Down walks back toward the present
6
+ * and restores the in-progress draft once you page past the newest entry.
7
+ *
8
+ * Kept free of DOM access so it can be unit-tested in the Node test env. The
9
+ * UI layer (`ui.ts`) supplies caret information and applies the returned value
10
+ * to the textarea.
11
+ */
12
+
13
+ export interface ComposerHistoryState {
14
+ /** Index into the history list, or -1 when not navigating. */
15
+ index: number;
16
+ /** The user's in-progress text, saved when navigation begins. */
17
+ draft: string;
18
+ }
19
+
20
+ export const INITIAL_HISTORY_STATE: ComposerHistoryState = {
21
+ index: -1,
22
+ draft: ""
23
+ };
24
+
25
+ export interface ComposerHistoryInput {
26
+ direction: "up" | "down";
27
+ /** Previously sent user-message texts, oldest first. */
28
+ history: string[];
29
+ /** Current textarea value (saved as the draft when navigation begins). */
30
+ currentValue: string;
31
+ /** True when the caret sits at the very start of the textarea. */
32
+ atStart: boolean;
33
+ state: ComposerHistoryState;
34
+ }
35
+
36
+ export interface ComposerHistoryResult {
37
+ /** Whether the key was consumed (caller should preventDefault). */
38
+ handled: boolean;
39
+ /** New textarea value to apply — only present when it should change. */
40
+ value?: string;
41
+ /** Next navigation state. */
42
+ state: ComposerHistoryState;
43
+ }
44
+
45
+ /**
46
+ * Compute the next navigation state for an Up/Down key press.
47
+ *
48
+ * - **Up** enters history only from the top boundary (`atStart`), then keeps
49
+ * stepping toward older messages on each subsequent press while navigating.
50
+ * - **Down** only acts while already navigating, stepping toward newer messages
51
+ * and finally restoring the saved draft once it walks past the newest entry.
52
+ */
53
+ export function navigateComposerHistory(
54
+ input: ComposerHistoryInput
55
+ ): ComposerHistoryResult {
56
+ const { direction, history, currentValue, atStart, state } = input;
57
+ const inHistory = state.index !== -1;
58
+
59
+ if (history.length === 0) {
60
+ return { handled: false, state };
61
+ }
62
+
63
+ if (direction === "up") {
64
+ // Only hijack Up from the top boundary so normal multi-line cursor
65
+ // movement keeps working until the user is actually cycling history.
66
+ if (!inHistory && !atStart) {
67
+ return { handled: false, state };
68
+ }
69
+
70
+ if (!inHistory) {
71
+ // First step: stash the draft and jump to the newest entry.
72
+ const index = history.length - 1;
73
+ return {
74
+ handled: true,
75
+ value: history[index],
76
+ state: { index, draft: currentValue }
77
+ };
78
+ }
79
+
80
+ if (state.index > 0) {
81
+ const index = state.index - 1;
82
+ return {
83
+ handled: true,
84
+ value: history[index],
85
+ state: { index, draft: state.draft }
86
+ };
87
+ }
88
+
89
+ // Already at the oldest entry — consume the key but don't change.
90
+ return { handled: true, state };
91
+ }
92
+
93
+ // direction === "down": only meaningful while navigating history.
94
+ if (!inHistory) {
95
+ return { handled: false, state };
96
+ }
97
+
98
+ if (state.index < history.length - 1) {
99
+ const index = state.index + 1;
100
+ return {
101
+ handled: true,
102
+ value: history[index],
103
+ state: { index, draft: state.draft }
104
+ };
105
+ }
106
+
107
+ // Stepped past the newest entry — restore the saved draft and exit.
108
+ return {
109
+ handled: true,
110
+ value: state.draft,
111
+ state: { ...INITIAL_HISTORY_STATE }
112
+ };
113
+ }
@@ -125,6 +125,26 @@ describe("computeMessageFingerprint", () => {
125
125
  expect(fp1).not.toBe(fp2);
126
126
  });
127
127
 
128
+ it("changes when reasoning content grows while chunk count stays at 1 (ordered streaming path)", () => {
129
+ // The sequenced/production path collapses reasoning to a single accumulated
130
+ // chunk (client.ts: `reasoning.chunks = [ordered]`), so chunks.length is
131
+ // permanently 1 while the text grows. A length-only fingerprint would freeze
132
+ // the reasoning bubble mid-stream; hashing the last chunk's length + tail
133
+ // keeps the cache invalidating on every delta.
134
+ const fp1 = computeMessageFingerprint(
135
+ makeMessage({ variant: "reasoning", reasoning: { chunks: ["I am thinking about"], status: "streaming" } }),
136
+ 0
137
+ );
138
+ const fp2 = computeMessageFingerprint(
139
+ makeMessage({
140
+ variant: "reasoning",
141
+ reasoning: { chunks: ["I am thinking about the user's question"], status: "streaming" },
142
+ }),
143
+ 0
144
+ );
145
+ expect(fp1).not.toBe(fp2);
146
+ });
147
+
128
148
  it("changes when contentParts length changes", () => {
129
149
  const fp1 = computeMessageFingerprint(makeMessage({ contentParts: [] }), 0);
130
150
  const fp2 = computeMessageFingerprint(makeMessage({ contentParts: [{ type: "text", text: "hi" }] }), 0);
@@ -63,6 +63,8 @@ export function computeMessageFingerprint(
63
63
  ? JSON.stringify(message.toolCall.args).length
64
64
  : 0,
65
65
  message.reasoning?.chunks?.length ?? 0,
66
+ message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.length ?? 0,
67
+ message.reasoning?.chunks?.[message.reasoning.chunks.length - 1]?.slice(-32) ?? "",
66
68
  message.contentParts?.length ?? 0,
67
69
  message.stopReason ?? "",
68
70
  configVersion,