@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.
@@ -210,14 +210,20 @@ export const THEME_TOKEN_DOCS = {
210
210
  'features.scrollToBottom.enabled, features.scrollToBottom.iconName, features.scrollToBottom.label (empty string renders icon-only). Defaults: enabled=true, iconName="arrow-down", label="".',
211
211
  },
212
212
  toolCall: {
213
- description: 'Tool call display styling and collapsed/grouped rendering hooks.',
213
+ description:
214
+ 'Tool call display styling, text templates, loading animations, and rendering hooks. ' +
215
+ 'Text templates support placeholders ({toolName}, {duration}) and inline formatting (~dim~, *italic*, **bold**). ' +
216
+ 'renderCollapsedSummary receives elapsed (static string) and createElapsedElement() (live-updating span) in its context.',
214
217
  properties:
215
- 'shadow, backgroundColor, borderColor, borderWidth, borderRadius, headerBackgroundColor, headerTextColor, headerPaddingX, headerPaddingY, contentBackgroundColor, contentTextColor, contentPaddingX, contentPaddingY, codeBlockBackgroundColor, codeBlockBorderColor, codeBlockTextColor, toggleTextColor, labelTextColor, renderCollapsedSummary, renderCollapsedPreview, renderGroupedSummary.',
218
+ 'shadow, backgroundColor, borderColor, borderWidth, borderRadius, headerBackgroundColor, headerTextColor, headerPaddingX, headerPaddingY, contentBackgroundColor, contentTextColor, contentPaddingX, contentPaddingY, codeBlockBackgroundColor, codeBlockBorderColor, codeBlockTextColor, toggleTextColor, labelTextColor, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration, renderCollapsedSummary, renderCollapsedPreview, renderGroupedSummary.',
216
219
  },
217
220
  reasoning: {
218
- description: 'Reasoning/thinking row rendering hooks.',
221
+ description:
222
+ 'Reasoning/thinking row rendering hooks, text templates, and loading animations. ' +
223
+ 'Text templates support {duration} placeholder and inline formatting (~dim~, *italic*, **bold**). ' +
224
+ 'renderCollapsedSummary receives elapsed (static string) and createElapsedElement() (live-updating span) in its context.',
219
225
  properties:
220
- 'renderCollapsedSummary, renderCollapsedPreview.',
226
+ 'renderCollapsedSummary, renderCollapsedPreview, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration.',
221
227
  },
222
228
  approval: {
223
229
  description:
@@ -282,7 +288,7 @@ export const THEME_TOKEN_DOCS = {
282
288
  features: {
283
289
  description: 'Feature flags.',
284
290
  properties:
285
- 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines), artifacts (sidebar config).',
291
+ 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable, loadingAnimation), artifacts (sidebar config).',
286
292
  },
287
293
  },
288
294
  }
@@ -10,6 +10,7 @@ describe("tool call display defaults", () => {
10
10
  grouped: false,
11
11
  previewMaxLines: 3,
12
12
  expandable: true,
13
+ loadingAnimation: "none",
13
14
  });
14
15
  });
15
16
 
@@ -18,6 +19,7 @@ describe("tool call display defaults", () => {
18
19
  activePreview: false,
19
20
  previewMaxLines: 3,
20
21
  expandable: true,
22
+ loadingAnimation: "none",
21
23
  });
22
24
  });
23
25
  });
package/src/types.ts CHANGED
@@ -577,6 +577,19 @@ export type AgentWidgetToolCallCollapsedMode =
577
577
  | "tool-name"
578
578
  | "tool-preview";
579
579
 
580
+ /**
581
+ * Animation mode applied to tool call header text while the tool is running.
582
+ * Character-by-character modes (`shimmer`, `shimmer-color`, `rainbow`) wrap each
583
+ * character in a span with staggered `animation-delay`. `pulse` applies to the
584
+ * entire text container. Honors `prefers-reduced-motion`.
585
+ */
586
+ export type AgentWidgetToolCallLoadingAnimation =
587
+ | "none"
588
+ | "pulse"
589
+ | "shimmer"
590
+ | "shimmer-color"
591
+ | "rainbow";
592
+
580
593
  export type AgentWidgetToolCallDisplayFeature = {
581
594
  /**
582
595
  * Controls what collapsed tool call rows show in their header/summary area.
@@ -590,6 +603,8 @@ export type AgentWidgetToolCallDisplayFeature = {
590
603
  activePreview?: boolean;
591
604
  /**
592
605
  * Optional CSS min-height applied to active collapsed tool call rows.
606
+ * @default undefined (no min-height)
607
+ * @example "100px"
593
608
  */
594
609
  activeMinHeight?: string;
595
610
  /**
@@ -608,6 +623,16 @@ export type AgentWidgetToolCallDisplayFeature = {
608
623
  * @default true
609
624
  */
610
625
  expandable?: boolean;
626
+ /**
627
+ * Animation mode applied to the tool call header text while the tool is active.
628
+ * - "none" — static text, no animation
629
+ * - "pulse" — opacity pulse on the entire header text
630
+ * - "shimmer" — monochrome opacity sweep per character
631
+ * - "shimmer-color" — color gradient sweep per character
632
+ * - "rainbow" — rainbow color cycle per character
633
+ * @default "none"
634
+ */
635
+ loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
611
636
  };
612
637
 
613
638
  export type AgentWidgetReasoningDisplayFeature = {
@@ -631,6 +656,17 @@ export type AgentWidgetReasoningDisplayFeature = {
631
656
  * @default true
632
657
  */
633
658
  expandable?: boolean;
659
+ /**
660
+ * Animation mode applied to the reasoning header text while reasoning is active.
661
+ * Reuses the same modes as tool call animations.
662
+ * - "none" — static text, no animation
663
+ * - "pulse" — opacity pulse on the entire header text
664
+ * - "shimmer" — monochrome opacity sweep per character
665
+ * - "shimmer-color" — color gradient sweep per character
666
+ * - "rainbow" — rainbow color cycle per character
667
+ * @default "none"
668
+ */
669
+ loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
634
670
  };
635
671
 
636
672
  export type AgentWidgetFeatureFlags = {
@@ -1234,22 +1270,39 @@ export type AgentWidgetApprovalConfig = {
1234
1270
  export type AgentWidgetToolCallConfig = {
1235
1271
  /** Box-shadow for tool-call bubbles; overrides `theme.toolBubbleShadow` when set. */
1236
1272
  shadow?: string;
1273
+ /** Background color of the tool call bubble container. */
1237
1274
  backgroundColor?: string;
1275
+ /** Border color of the tool call bubble container. */
1238
1276
  borderColor?: string;
1277
+ /** Border width of the tool call bubble container (CSS value, e.g. `"1px"`). */
1239
1278
  borderWidth?: string;
1279
+ /** Border radius of the tool call bubble container (CSS value, e.g. `"12px"`). */
1240
1280
  borderRadius?: string;
1281
+ /** Background color of the collapsed header row. */
1241
1282
  headerBackgroundColor?: string;
1283
+ /** Text color of the collapsed header row (tool name / summary). */
1242
1284
  headerTextColor?: string;
1285
+ /** Horizontal padding of the collapsed header row (CSS value). */
1243
1286
  headerPaddingX?: string;
1287
+ /** Vertical padding of the collapsed header row (CSS value). */
1244
1288
  headerPaddingY?: string;
1289
+ /** Background color of the expanded content area. */
1245
1290
  contentBackgroundColor?: string;
1291
+ /** Text color of the expanded content area. */
1246
1292
  contentTextColor?: string;
1293
+ /** Horizontal padding of the expanded content area (CSS value). */
1247
1294
  contentPaddingX?: string;
1295
+ /** Vertical padding of the expanded content area (CSS value). */
1248
1296
  contentPaddingY?: string;
1297
+ /** Background color of code blocks (arguments / result) in the expanded area. */
1249
1298
  codeBlockBackgroundColor?: string;
1299
+ /** Border color of code blocks in the expanded area. */
1250
1300
  codeBlockBorderColor?: string;
1301
+ /** Text color of code blocks in the expanded area. */
1251
1302
  codeBlockTextColor?: string;
1303
+ /** Color of the expand/collapse toggle icon. */
1252
1304
  toggleTextColor?: string;
1305
+ /** Color of section labels ("Arguments", "Result", "Activity") in the expanded area. */
1253
1306
  labelTextColor?: string;
1254
1307
  /**
1255
1308
  * Override the collapsed summary row content for a tool call bubble.
@@ -1263,6 +1316,14 @@ export type AgentWidgetToolCallConfig = {
1263
1316
  collapsedMode: AgentWidgetToolCallCollapsedMode;
1264
1317
  isActive: boolean;
1265
1318
  config: AgentWidgetConfig;
1319
+ /** Static elapsed time snapshot, e.g. "2.6s". */
1320
+ elapsed: string;
1321
+ /**
1322
+ * Returns a `<span>` whose text content is automatically updated every
1323
+ * 100ms by the widget's global timer. Place it anywhere in your returned
1324
+ * HTMLElement to get a live-ticking duration display.
1325
+ */
1326
+ createElapsedElement: () => HTMLElement;
1266
1327
  }) => HTMLElement | string | null;
1267
1328
  /**
1268
1329
  * Override the lightweight collapsed preview content shown for active tool rows.
@@ -1285,6 +1346,47 @@ export type AgentWidgetToolCallConfig = {
1285
1346
  defaultSummary: string;
1286
1347
  config: AgentWidgetConfig;
1287
1348
  }) => HTMLElement | string | null;
1349
+ /**
1350
+ * Template string for the header text while a tool call is active (running).
1351
+ *
1352
+ * **Placeholders:** `{toolName}` (tool name), `{duration}` (live-updating elapsed time).
1353
+ *
1354
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — parsed at render time and
1355
+ * applied as styled `<span>` elements. Works with all animation modes.
1356
+ *
1357
+ * When not set, falls back to the current `collapsedMode` behavior.
1358
+ * @example "Calling {toolName}... ~{duration}~"
1359
+ * @example "**Searching** *{toolName}*..."
1360
+ */
1361
+ activeTextTemplate?: string;
1362
+ /**
1363
+ * Template string for the header text when a tool call is complete.
1364
+ *
1365
+ * **Placeholders:** `{toolName}` (tool name), `{duration}` (final elapsed time).
1366
+ *
1367
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — same syntax as `activeTextTemplate`.
1368
+ *
1369
+ * When not set, falls back to the existing "Used tool for X seconds" text.
1370
+ * @example "Finished {toolName} ~{duration}~"
1371
+ */
1372
+ completeTextTemplate?: string;
1373
+ /**
1374
+ * Primary color for shimmer-color animation mode.
1375
+ * Defaults to the current text color.
1376
+ */
1377
+ loadingAnimationColor?: string;
1378
+ /**
1379
+ * Secondary/end color for shimmer-color animation mode.
1380
+ * Creates a gradient sweep between `loadingAnimationColor` and this color.
1381
+ * @default "#3b82f6"
1382
+ */
1383
+ loadingAnimationSecondaryColor?: string;
1384
+ /**
1385
+ * Duration of one full animation cycle in milliseconds.
1386
+ * Applies to pulse, shimmer, shimmer-color, and rainbow modes.
1387
+ * @default 2000
1388
+ */
1389
+ loadingAnimationDuration?: number;
1288
1390
  };
1289
1391
 
1290
1392
  export type AgentWidgetReasoningConfig = {
@@ -1299,6 +1401,14 @@ export type AgentWidgetReasoningConfig = {
1299
1401
  previewText: string;
1300
1402
  isActive: boolean;
1301
1403
  config: AgentWidgetConfig;
1404
+ /** Static elapsed time snapshot, e.g. "2.6s". */
1405
+ elapsed: string;
1406
+ /**
1407
+ * Returns a `<span>` whose text content is automatically updated every
1408
+ * 100ms by the widget's global timer. Place it anywhere in your returned
1409
+ * HTMLElement to get a live-ticking duration display.
1410
+ */
1411
+ createElapsedElement: () => HTMLElement;
1302
1412
  }) => HTMLElement | string | null;
1303
1413
  /**
1304
1414
  * Override the lightweight collapsed preview content shown for active reasoning rows.
@@ -1311,6 +1421,45 @@ export type AgentWidgetReasoningConfig = {
1311
1421
  isActive: boolean;
1312
1422
  config: AgentWidgetConfig;
1313
1423
  }) => HTMLElement | string | null;
1424
+ /**
1425
+ * Template string for the header text while reasoning is active (streaming).
1426
+ *
1427
+ * **Placeholders:** `{duration}` (live-updating elapsed time).
1428
+ *
1429
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — parsed at render time.
1430
+ *
1431
+ * When not set, falls back to the default "Thinking..." text.
1432
+ * @example "Thinking... ~{duration}~"
1433
+ */
1434
+ activeTextTemplate?: string;
1435
+ /**
1436
+ * Template string for the header text when reasoning is complete.
1437
+ *
1438
+ * **Placeholders:** `{duration}` (final elapsed time).
1439
+ *
1440
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — same syntax as `activeTextTemplate`.
1441
+ *
1442
+ * When not set, falls back to the default "Thought for X seconds" text.
1443
+ * @example "Thought for ~{duration}~"
1444
+ */
1445
+ completeTextTemplate?: string;
1446
+ /**
1447
+ * Primary color for shimmer-color animation mode.
1448
+ * Defaults to the current text color.
1449
+ */
1450
+ loadingAnimationColor?: string;
1451
+ /**
1452
+ * Secondary/end color for shimmer-color animation mode.
1453
+ * Creates a gradient sweep between `loadingAnimationColor` and this color.
1454
+ * @default "#3b82f6"
1455
+ */
1456
+ loadingAnimationSecondaryColor?: string;
1457
+ /**
1458
+ * Duration of one full animation cycle in milliseconds.
1459
+ * Applies to pulse, shimmer, shimmer-color, and rainbow modes.
1460
+ * @default 2000
1461
+ */
1462
+ loadingAnimationDuration?: number;
1314
1463
  };
1315
1464
 
1316
1465
  export type AgentWidgetSuggestionChipsConfig = {
@@ -34,6 +34,16 @@ const installRafMock = () => {
34
34
  });
35
35
 
36
36
  return {
37
+ step(frameCount = 1) {
38
+ let frames = 0;
39
+ while (callbacks.size > 0 && frames < frameCount) {
40
+ const pending = [...callbacks.entries()];
41
+ callbacks.clear();
42
+ frames += 1;
43
+ now += 16;
44
+ pending.forEach(([, callback]) => callback(now));
45
+ }
46
+ },
37
47
  flush(maxFrames = 80) {
38
48
  let frames = 0;
39
49
  while (callbacks.size > 0 && frames < maxFrames) {
@@ -215,14 +225,14 @@ describe("createAgentExperience streaming scroll", () => {
215
225
 
216
226
  expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
217
227
 
218
- metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
228
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 6);
219
229
  scrollContainer!.dispatchEvent(new Event("scroll"));
220
230
 
221
231
  metrics.setScrollHeight(1040);
222
232
  emitStreamingMessage(controller, "Second chunk");
223
233
  raf.flush();
224
234
 
225
- expect(metrics.getScrollTop()).toBe(597);
235
+ expect(metrics.getScrollTop()).toBe(594);
226
236
 
227
237
  controller.destroy();
228
238
  });
@@ -364,6 +374,39 @@ describe("createAgentExperience streaming scroll", () => {
364
374
  controller.destroy();
365
375
  });
366
376
 
377
+ it("catches up immediately when a streamed update lands far behind", () => {
378
+ const raf = installRafMock();
379
+ const mount = createMount();
380
+ const controller = createAgentExperience(mount, {
381
+ apiUrl: "https://api.example.com/chat",
382
+ launcher: { enabled: false }
383
+ });
384
+
385
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
386
+ expect(scrollContainer).not.toBeNull();
387
+
388
+ const metrics = installScrollMetrics(scrollContainer!, {
389
+ scrollHeight: 900,
390
+ clientHeight: 400
391
+ });
392
+
393
+ emitStreamingStatus(controller);
394
+ emitStreamingMessage(controller, "Chunk one");
395
+ raf.flush();
396
+
397
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
398
+
399
+ metrics.setScrollHeight(1080);
400
+ emitStreamingMessage(controller, "Chunk two");
401
+
402
+ // Only run the scheduled auto-scroll frame, not the whole animation.
403
+ raf.step(1);
404
+
405
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
406
+
407
+ controller.destroy();
408
+ });
409
+
367
410
  it("lets the user break away during reasoning streaming", () => {
368
411
  const raf = installRafMock();
369
412
  const mount = createMount();
package/src/ui.ts CHANGED
@@ -54,6 +54,7 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
54
54
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
55
55
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
56
56
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
57
+ import { formatElapsedMs } from "./utils/formatting";
57
58
  import { createApprovalBubble } from "./components/approval-bubble";
58
59
  import { createSuggestions } from "./components/suggestions";
59
60
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
@@ -1989,8 +1990,13 @@ export const createAgentExperience = (
1989
1990
  let isAutoScrolling = false;
1990
1991
  let hasPendingAutoScroll = false;
1991
1992
 
1992
- const USER_SCROLL_THRESHOLD = 1;
1993
- const BOTTOM_THRESHOLD = 8;
1993
+ // Scroll events caused by layout, scroll anchoring, and smooth-scroll
1994
+ // easing can easily move by a couple pixels. Keep manual wheel intent
1995
+ // responsive, but require a slightly larger raw scroll delta before we
1996
+ // treat a plain scroll event as the user breaking away.
1997
+ const USER_SCROLL_THRESHOLD = 4;
1998
+ const BOTTOM_THRESHOLD = 24;
1999
+ const AUTO_SCROLL_SNAP_THRESHOLD = 80;
1994
2000
  const messageState = new Map<
1995
2001
  string,
1996
2002
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -2177,6 +2183,18 @@ export const createAgentExperience = (
2177
2183
  return;
2178
2184
  }
2179
2185
 
2186
+ // If the transcript has fallen noticeably behind, catch up immediately
2187
+ // instead of easing over multiple frames. This keeps fast streaming /
2188
+ // bursty tool and reasoning updates pinned to the bottom.
2189
+ if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
2190
+ cancelSmoothScroll();
2191
+ isAutoScrolling = true;
2192
+ element.scrollTop = target;
2193
+ lastScrollTop = element.scrollTop;
2194
+ isAutoScrolling = false;
2195
+ return;
2196
+ }
2197
+
2180
2198
  // Cancel any ongoing smooth scroll animation
2181
2199
  cancelSmoothScroll();
2182
2200
 
@@ -2960,9 +2978,33 @@ export const createAgentExperience = (
2960
2978
  };
2961
2979
  }
2962
2980
 
2981
+ // Global timer for live-updating tool elapsed time spans.
2982
+ // Runs at 100ms while any [data-tool-elapsed] span exists in the message area,
2983
+ // auto-stops when none remain. Operates on real DOM after morph, not temp elements.
2984
+ let toolElapsedTimerId: ReturnType<typeof setInterval> | null = null;
2985
+ const ensureToolElapsedTimer = () => {
2986
+ if (toolElapsedTimerId != null) return;
2987
+ toolElapsedTimerId = setInterval(() => {
2988
+ const spans = messagesWrapper.querySelectorAll<HTMLElement>("[data-tool-elapsed]");
2989
+ if (spans.length === 0) {
2990
+ clearInterval(toolElapsedTimerId!);
2991
+ toolElapsedTimerId = null;
2992
+ return;
2993
+ }
2994
+ const now = Date.now();
2995
+ spans.forEach((span) => {
2996
+ const startedAt = Number(span.getAttribute("data-tool-elapsed"));
2997
+ if (!startedAt) return;
2998
+ span.textContent = formatElapsedMs(now - startedAt);
2999
+ });
3000
+ }, 100);
3001
+ };
3002
+
2963
3003
  session = new AgentWidgetSession(config, {
2964
3004
  onMessagesChanged(messages) {
2965
3005
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
3006
+ // Start elapsed timer if any active tool has a live duration span
3007
+ ensureToolElapsedTimer();
2966
3008
  // Re-render suggestions to hide them after first user message
2967
3009
  // Pass messages directly to avoid calling session.getMessages() during construction
2968
3010
  if (session) {
@@ -5716,6 +5758,10 @@ export const createAgentExperience = (
5716
5758
  return session.submitNPSFeedback(rating, comment);
5717
5759
  },
5718
5760
  destroy() {
5761
+ if (toolElapsedTimerId != null) {
5762
+ clearInterval(toolElapsedTimerId);
5763
+ toolElapsedTimerId = null;
5764
+ }
5719
5765
  destroyCallbacks.forEach((cb) => cb());
5720
5766
  wrapper.remove();
5721
5767
  launcherButtonInstance?.destroy();
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } 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,101 @@ 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
+ });
247
+
248
+ describe("computeReasoningElapsed", () => {
249
+ it("uses durationMs when provided", () => {
250
+ const result = computeReasoningElapsed({
251
+ id: "r1", status: "complete", chunks: [], durationMs: 2600,
252
+ });
253
+ expect(result).toBe("2.6s");
254
+ });
255
+
256
+ it("computes from startedAt/completedAt when durationMs is undefined", () => {
257
+ const result = computeReasoningElapsed({
258
+ id: "r2", status: "complete", chunks: [],
259
+ startedAt: 1000, completedAt: 16000,
260
+ });
261
+ expect(result).toBe("15s");
262
+ });
263
+
264
+ it("returns <0.1s for very short durations", () => {
265
+ const result = computeReasoningElapsed({
266
+ id: "r3", status: "complete", chunks: [], durationMs: 50,
267
+ });
268
+ expect(result).toBe("<0.1s");
269
+ });
270
+ });