@runtypelabs/persona 3.10.1 → 3.11.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.
- package/dist/index.cjs +44 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.global.js +61 -61
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +44 -44
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +195 -12
- package/dist/theme-editor.d.cts +85 -0
- package/dist/theme-editor.d.ts +85 -0
- package/dist/theme-editor.js +195 -12
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +80 -0
- package/package.json +1 -1
- package/src/components/tool-bubble.ts +121 -1
- package/src/defaults.ts +1 -0
- package/src/styles/widget.css +80 -0
- package/src/theme-reference.ts +6 -3
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types.ts +91 -0
- package/src/ui.scroll.test.ts +45 -2
- package/src/ui.ts +48 -2
- package/src/utils/formatting.test.ts +75 -1
- package/src/utils/formatting.ts +130 -0
- package/src/utils/morph.ts +9 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createJsonStreamParser } from "./formatting";
|
|
2
|
+
import { createJsonStreamParser, parseFormattedTemplate } from "./formatting";
|
|
3
3
|
|
|
4
4
|
describe("JSON Stream Parser", () => {
|
|
5
5
|
it("should extract text field incrementally as JSON streams in", () => {
|
|
@@ -170,3 +170,77 @@ describe("JSON Stream Parser", () => {
|
|
|
170
170
|
expect(finalResult).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("parseFormattedTemplate", () => {
|
|
175
|
+
it("returns plain text segments when no formatting markers are present", () => {
|
|
176
|
+
const segments = parseFormattedTemplate("Calling {toolName}...", "Get Weather");
|
|
177
|
+
expect(segments).toEqual([
|
|
178
|
+
{ text: "Calling Get Weather...", styles: [] },
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("resolves {toolName} placeholder", () => {
|
|
183
|
+
const segments = parseFormattedTemplate("{toolName} running", "Search Catalog");
|
|
184
|
+
expect(segments).toEqual([
|
|
185
|
+
{ text: "Search Catalog running", styles: [] },
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("parses ~dim~ markers", () => {
|
|
190
|
+
const segments = parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather");
|
|
191
|
+
expect(segments).toEqual([
|
|
192
|
+
{ text: "Finished Get Weather ", styles: [] },
|
|
193
|
+
{ text: "{duration}", styles: ["dim"], isDuration: true },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("parses *italic* markers", () => {
|
|
198
|
+
const segments = parseFormattedTemplate("*{toolName}* completed", "Search");
|
|
199
|
+
expect(segments).toEqual([
|
|
200
|
+
{ text: "Search", styles: ["italic"] },
|
|
201
|
+
{ text: " completed", styles: [] },
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("parses **bold** markers", () => {
|
|
206
|
+
const segments = parseFormattedTemplate("**Calling** {toolName}", "Lookup");
|
|
207
|
+
expect(segments).toEqual([
|
|
208
|
+
{ text: "Calling", styles: ["bold"] },
|
|
209
|
+
{ text: " Lookup", styles: [] },
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("handles multiple formatting markers in one template", () => {
|
|
214
|
+
const segments = parseFormattedTemplate("**Done** *{toolName}* ~{duration}~", "API");
|
|
215
|
+
expect(segments).toEqual([
|
|
216
|
+
{ text: "Done", styles: ["bold"] },
|
|
217
|
+
{ text: " ", styles: [] },
|
|
218
|
+
{ text: "API", styles: ["italic"] },
|
|
219
|
+
{ text: " ", styles: [] },
|
|
220
|
+
{ text: "{duration}", styles: ["dim"], isDuration: true },
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("handles {duration} without formatting markers", () => {
|
|
225
|
+
const segments = parseFormattedTemplate("Ran for {duration}", "Tool");
|
|
226
|
+
expect(segments).toEqual([
|
|
227
|
+
{ text: "Ran for ", styles: [] },
|
|
228
|
+
{ text: "{duration}", styles: [], isDuration: true },
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("handles template with no placeholders", () => {
|
|
233
|
+
const segments = parseFormattedTemplate("Running...", "Ignored");
|
|
234
|
+
expect(segments).toEqual([
|
|
235
|
+
{ text: "Running...", styles: [] },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("handles empty tool name fallback in template", () => {
|
|
240
|
+
const segments = parseFormattedTemplate("{toolName}", " ");
|
|
241
|
+
// toolName is resolved before parsing, so whitespace stays
|
|
242
|
+
expect(segments).toEqual([
|
|
243
|
+
{ text: " ", styles: [] },
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
});
|
package/src/utils/formatting.ts
CHANGED
|
@@ -87,6 +87,136 @@ 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
|
+
* Resolves a text template with tool call placeholders.
|
|
120
|
+
* Supported placeholders: {toolName}, {duration}
|
|
121
|
+
* Returns the fallback if template is undefined.
|
|
122
|
+
*/
|
|
123
|
+
export const resolveToolHeaderText = (
|
|
124
|
+
tool: AgentWidgetToolCall,
|
|
125
|
+
template: string | undefined,
|
|
126
|
+
fallback: string
|
|
127
|
+
): string => {
|
|
128
|
+
if (!template) return fallback;
|
|
129
|
+
|
|
130
|
+
const toolName = tool.name?.trim() || "tool";
|
|
131
|
+
const duration = computeToolElapsed(tool);
|
|
132
|
+
|
|
133
|
+
return template
|
|
134
|
+
.replace(/\{toolName\}/g, toolName)
|
|
135
|
+
.replace(/\{duration\}/g, duration);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A segment of parsed template text with optional inline formatting.
|
|
140
|
+
*/
|
|
141
|
+
export interface TemplateSegment {
|
|
142
|
+
/** The text content (or "{duration}" for duration placeholders) */
|
|
143
|
+
text: string;
|
|
144
|
+
/** CSS modifier names to apply: "dim", "bold", "italic" */
|
|
145
|
+
styles: string[];
|
|
146
|
+
/** True when this segment represents a {duration} placeholder */
|
|
147
|
+
isDuration?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parses a template string with inline formatting markers into segments.
|
|
152
|
+
*
|
|
153
|
+
* Supported markers (Markdown-like):
|
|
154
|
+
* - `**text**` → bold
|
|
155
|
+
* - `*text*` → italic
|
|
156
|
+
* - `~text~` → dim / muted
|
|
157
|
+
*
|
|
158
|
+
* Placeholders `{toolName}` are resolved; `{duration}` is preserved as a
|
|
159
|
+
* typed segment so the caller can render it as a live-updating DOM node.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather")
|
|
163
|
+
* // → [
|
|
164
|
+
* // { text: "Finished Get Weather ", styles: [] },
|
|
165
|
+
* // { text: "{duration}", styles: ["dim"], isDuration: true }
|
|
166
|
+
* // ]
|
|
167
|
+
*/
|
|
168
|
+
export const parseFormattedTemplate = (
|
|
169
|
+
template: string,
|
|
170
|
+
toolName: string
|
|
171
|
+
): TemplateSegment[] => {
|
|
172
|
+
const resolved = template.replace(/\{toolName\}/g, toolName);
|
|
173
|
+
const segments: TemplateSegment[] = [];
|
|
174
|
+
// Order matters: ** must match before *
|
|
175
|
+
const regex = /\*\*(.+?)\*\*|\*(.+?)\*|~(.+?)~/g;
|
|
176
|
+
|
|
177
|
+
let lastIndex = 0;
|
|
178
|
+
let match;
|
|
179
|
+
|
|
180
|
+
while ((match = regex.exec(resolved)) !== null) {
|
|
181
|
+
if (match.index > lastIndex) {
|
|
182
|
+
pushSegments(segments, resolved.slice(lastIndex, match.index), []);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (match[1] !== undefined) {
|
|
186
|
+
pushSegments(segments, match[1], ["bold"]);
|
|
187
|
+
} else if (match[2] !== undefined) {
|
|
188
|
+
pushSegments(segments, match[2], ["italic"]);
|
|
189
|
+
} else if (match[3] !== undefined) {
|
|
190
|
+
pushSegments(segments, match[3], ["dim"]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lastIndex = match.index + match[0].length;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (lastIndex < resolved.length) {
|
|
197
|
+
pushSegments(segments, resolved.slice(lastIndex), []);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return segments;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Splits text on {duration} and pushes typed segments. */
|
|
204
|
+
const pushSegments = (
|
|
205
|
+
segments: TemplateSegment[],
|
|
206
|
+
text: string,
|
|
207
|
+
styles: string[]
|
|
208
|
+
): void => {
|
|
209
|
+
const parts = text.split("{duration}");
|
|
210
|
+
for (let i = 0; i < parts.length; i++) {
|
|
211
|
+
if (parts[i]) {
|
|
212
|
+
segments.push({ text: parts[i], styles });
|
|
213
|
+
}
|
|
214
|
+
if (i < parts.length - 1) {
|
|
215
|
+
segments.push({ text: "{duration}", styles, isDuration: true });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
90
220
|
/**
|
|
91
221
|
* Creates a regex-based parser for extracting text from JSON streams.
|
|
92
222
|
* This is a simpler alternative to schema-stream that uses regex to extract
|
package/src/utils/morph.ts
CHANGED
|
@@ -21,14 +21,20 @@ export const morphMessages = (
|
|
|
21
21
|
Idiomorph.morph(container, newContent.innerHTML, {
|
|
22
22
|
morphStyle: "innerHTML",
|
|
23
23
|
callbacks: {
|
|
24
|
-
beforeNodeMorphed(oldNode: Node,
|
|
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
|
-
|
|
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
|
+
}
|
|
32
38
|
return false;
|
|
33
39
|
}
|
|
34
40
|
}
|