@runtypelabs/persona 3.10.1 → 3.12.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.
@@ -87,6 +87,151 @@ export const describeToolTitle = (tool: AgentWidgetToolCall) => {
87
87
  return "Using tool...";
88
88
  };
89
89
 
90
+ /**
91
+ * Formats a millisecond duration as a short human-readable string.
92
+ * Returns "2.3s", "15s", or "<0.1s".
93
+ */
94
+ export const formatElapsedMs = (ms: number): string => {
95
+ const seconds = ms / 1000;
96
+ if (seconds < 0.1) return "<0.1s";
97
+ if (seconds >= 10) return `${Math.round(seconds)}s`;
98
+ return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
99
+ };
100
+
101
+ /**
102
+ * Computes the current elapsed time string for a tool call.
103
+ */
104
+ export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
105
+ const durationMs =
106
+ typeof tool.duration === "number"
107
+ ? tool.duration
108
+ : typeof tool.durationMs === "number"
109
+ ? tool.durationMs
110
+ : Math.max(
111
+ 0,
112
+ (tool.completedAt ?? Date.now()) -
113
+ (tool.startedAt ?? tool.completedAt ?? Date.now())
114
+ );
115
+ return formatElapsedMs(durationMs);
116
+ };
117
+
118
+ /**
119
+ * Computes the current elapsed time string for a reasoning block.
120
+ */
121
+ export const computeReasoningElapsed = (reasoning: AgentWidgetReasoning): string => {
122
+ const durationMs =
123
+ reasoning.durationMs !== undefined
124
+ ? reasoning.durationMs
125
+ : Math.max(
126
+ 0,
127
+ (reasoning.completedAt ?? Date.now()) -
128
+ (reasoning.startedAt ?? reasoning.completedAt ?? Date.now())
129
+ );
130
+ return formatElapsedMs(durationMs);
131
+ };
132
+
133
+ /**
134
+ * Resolves a text template with tool call placeholders.
135
+ * Supported placeholders: {toolName}, {duration}
136
+ * Returns the fallback if template is undefined.
137
+ */
138
+ export const resolveToolHeaderText = (
139
+ tool: AgentWidgetToolCall,
140
+ template: string | undefined,
141
+ fallback: string
142
+ ): string => {
143
+ if (!template) return fallback;
144
+
145
+ const toolName = tool.name?.trim() || "tool";
146
+ const duration = computeToolElapsed(tool);
147
+
148
+ return template
149
+ .replace(/\{toolName\}/g, toolName)
150
+ .replace(/\{duration\}/g, duration);
151
+ };
152
+
153
+ /**
154
+ * A segment of parsed template text with optional inline formatting.
155
+ */
156
+ export interface TemplateSegment {
157
+ /** The text content (or "{duration}" for duration placeholders) */
158
+ text: string;
159
+ /** CSS modifier names to apply: "dim", "bold", "italic" */
160
+ styles: string[];
161
+ /** True when this segment represents a {duration} placeholder */
162
+ isDuration?: boolean;
163
+ }
164
+
165
+ /**
166
+ * Parses a template string with inline formatting markers into segments.
167
+ *
168
+ * Supported markers (Markdown-like):
169
+ * - `**text**` → bold
170
+ * - `*text*` → italic
171
+ * - `~text~` → dim / muted
172
+ *
173
+ * Placeholders `{toolName}` are resolved; `{duration}` is preserved as a
174
+ * typed segment so the caller can render it as a live-updating DOM node.
175
+ *
176
+ * @example
177
+ * parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather")
178
+ * // → [
179
+ * // { text: "Finished Get Weather ", styles: [] },
180
+ * // { text: "{duration}", styles: ["dim"], isDuration: true }
181
+ * // ]
182
+ */
183
+ export const parseFormattedTemplate = (
184
+ template: string,
185
+ toolName: string
186
+ ): TemplateSegment[] => {
187
+ const resolved = template.replace(/\{toolName\}/g, toolName);
188
+ const segments: TemplateSegment[] = [];
189
+ // Order matters: ** must match before *
190
+ const regex = /\*\*(.+?)\*\*|\*(.+?)\*|~(.+?)~/g;
191
+
192
+ let lastIndex = 0;
193
+ let match;
194
+
195
+ while ((match = regex.exec(resolved)) !== null) {
196
+ if (match.index > lastIndex) {
197
+ pushSegments(segments, resolved.slice(lastIndex, match.index), []);
198
+ }
199
+
200
+ if (match[1] !== undefined) {
201
+ pushSegments(segments, match[1], ["bold"]);
202
+ } else if (match[2] !== undefined) {
203
+ pushSegments(segments, match[2], ["italic"]);
204
+ } else if (match[3] !== undefined) {
205
+ pushSegments(segments, match[3], ["dim"]);
206
+ }
207
+
208
+ lastIndex = match.index + match[0].length;
209
+ }
210
+
211
+ if (lastIndex < resolved.length) {
212
+ pushSegments(segments, resolved.slice(lastIndex), []);
213
+ }
214
+
215
+ return segments;
216
+ };
217
+
218
+ /** Splits text on {duration} and pushes typed segments. */
219
+ const pushSegments = (
220
+ segments: TemplateSegment[],
221
+ text: string,
222
+ styles: string[]
223
+ ): void => {
224
+ const parts = text.split("{duration}");
225
+ for (let i = 0; i < parts.length; i++) {
226
+ if (parts[i]) {
227
+ segments.push({ text: parts[i], styles });
228
+ }
229
+ if (i < parts.length - 1) {
230
+ segments.push({ text: "{duration}", styles, isDuration: true });
231
+ }
232
+ }
233
+ };
234
+
90
235
  /**
91
236
  * Creates a regex-based parser for extracting text from JSON streams.
92
237
  * This is a simpler alternative to schema-stream that uses regex to extract
@@ -90,6 +90,18 @@ describe("computeMessageFingerprint", () => {
90
90
  expect(fp1).not.toBe(fp2);
91
91
  });
92
92
 
93
+ it("changes when toolCall name changes", () => {
94
+ const fp1 = computeMessageFingerprint(
95
+ makeMessage({ toolCall: { status: "running" } }),
96
+ 0
97
+ );
98
+ const fp2 = computeMessageFingerprint(
99
+ makeMessage({ toolCall: { status: "running", name: "UCP Search Catalog" } }),
100
+ 0
101
+ );
102
+ expect(fp1).not.toBe(fp2);
103
+ });
104
+
93
105
  it("changes when toolCall chunks change", () => {
94
106
  const fp1 = computeMessageFingerprint(
95
107
  makeMessage({ toolCall: { status: "running", chunks: ["Loaded tools"] } }),
@@ -53,6 +53,7 @@ export function computeMessageFingerprint(
53
53
  message.llmContent?.length ?? 0,
54
54
  message.approval?.status ?? "",
55
55
  message.toolCall?.status ?? "",
56
+ message.toolCall?.name ?? "",
56
57
  message.toolCall?.chunks?.length ?? 0,
57
58
  message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
58
59
  typeof message.toolCall?.args === "string"
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { morphMessages } from "./morph";
4
+
5
+ function makeContainer(html: string): HTMLElement {
6
+ const div = document.createElement("div");
7
+ div.innerHTML = html;
8
+ return div;
9
+ }
10
+
11
+ function makeNewContent(html: string): HTMLElement {
12
+ const div = document.createElement("div");
13
+ div.innerHTML = html;
14
+ return div;
15
+ }
16
+
17
+ describe("morphMessages", () => {
18
+ describe("data-preserve-animation", () => {
19
+ it("preserves animated element when old and new both have data-preserve-animation with same text", () => {
20
+ const container = makeContainer(
21
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
22
+ );
23
+ const oldSpan = container.querySelector("span")!;
24
+
25
+ morphMessages(
26
+ container,
27
+ makeNewContent(
28
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
29
+ )
30
+ );
31
+
32
+ expect(container.querySelector("span")).toBe(oldSpan);
33
+ });
34
+
35
+ it("allows morph when new node drops data-preserve-animation (tool completed)", () => {
36
+ const container = makeContainer(
37
+ '<span data-preserve-animation="true">Calling tool... 0.5s</span>'
38
+ );
39
+
40
+ morphMessages(
41
+ container,
42
+ makeNewContent("<span>Finished tool 0.5s</span>")
43
+ );
44
+
45
+ expect(container.querySelector("span")!.textContent).toBe(
46
+ "Finished tool 0.5s"
47
+ );
48
+ expect(
49
+ container.querySelector("span")!.hasAttribute("data-preserve-animation")
50
+ ).toBe(false);
51
+ });
52
+
53
+ it("allows morph when text content changes despite both having data-preserve-animation", () => {
54
+ const container = makeContainer(
55
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
56
+ );
57
+
58
+ morphMessages(
59
+ container,
60
+ makeNewContent(
61
+ '<span data-preserve-animation="true">Calling UCP Search Catalog... 0.2s</span>'
62
+ )
63
+ );
64
+
65
+ expect(container.querySelector("span")!.textContent).toBe(
66
+ "Calling UCP Search Catalog... 0.2s"
67
+ );
68
+ });
69
+
70
+ it("does not preserve when preserveTypingAnimation is false", () => {
71
+ const container = makeContainer(
72
+ '<span data-preserve-animation="true">Old text</span>'
73
+ );
74
+
75
+ morphMessages(
76
+ container,
77
+ makeNewContent(
78
+ '<span data-preserve-animation="true">New text</span>'
79
+ ),
80
+ { preserveTypingAnimation: false }
81
+ );
82
+
83
+ expect(container.querySelector("span")!.textContent).toBe("New text");
84
+ });
85
+ });
86
+ });
@@ -21,14 +21,28 @@ export const morphMessages = (
21
21
  Idiomorph.morph(container, newContent.innerHTML, {
22
22
  morphStyle: "innerHTML",
23
23
  callbacks: {
24
- beforeNodeMorphed(oldNode: Node, _newNode: Node): boolean | void {
24
+ beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
25
25
  if (!(oldNode instanceof HTMLElement)) return;
26
26
 
27
27
  // Preserve typing indicator dots to maintain animation continuity
28
28
  // Also preserve elements with data-preserve-animation attribute for custom loading indicators
29
29
  if (preserveTypingAnimation) {
30
- if (oldNode.classList.contains("persona-animate-typing") ||
31
- oldNode.hasAttribute("data-preserve-animation")) {
30
+ if (oldNode.classList.contains("persona-animate-typing")) {
31
+ return false;
32
+ }
33
+ if (oldNode.hasAttribute("data-preserve-animation")) {
34
+ // Allow morph when the new node drops the attribute (e.g. tool completed)
35
+ if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
36
+ return;
37
+ }
38
+ // Allow morph when content has meaningfully changed (e.g. tool name arrived)
39
+ if (newNode instanceof HTMLElement && newNode.hasAttribute("data-preserve-animation")) {
40
+ const oldText = oldNode.textContent ?? "";
41
+ const newText = newNode.textContent ?? "";
42
+ if (oldText !== newText) {
43
+ return;
44
+ }
45
+ }
32
46
  return false;
33
47
  }
34
48
  }