@esso0428/pi-subagents 0.15.14 → 0.15.16

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 (44) hide show
  1. package/CHANGELOG.md +9 -32
  2. package/dist/ui/agent-widget.d.ts +8 -0
  3. package/dist/ui/agent-widget.d.ts.map +1 -1
  4. package/dist/ui/agent-widget.js +40 -12
  5. package/dist/ui/agent-widget.js.map +1 -1
  6. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  7. package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
  8. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  10. package/dist/ui/ccstyle/tool-renderer.js +4 -10
  11. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.d.ts +9 -16
  13. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  14. package/dist/ui/ccstyle/tool-result.js +71 -121
  15. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  16. package/dist/ui/conversation-timeline.d.ts +14 -9
  17. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  18. package/dist/ui/conversation-timeline.js +60 -72
  19. package/dist/ui/conversation-timeline.js.map +1 -1
  20. package/dist/ui/conversation-viewer.d.ts +4 -1
  21. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  22. package/dist/ui/conversation-viewer.js +95 -94
  23. package/dist/ui/conversation-viewer.js.map +1 -1
  24. package/dist/write-execution.d.ts +0 -2
  25. package/dist/write-execution.d.ts.map +1 -1
  26. package/dist/write-execution.js +1 -5
  27. package/dist/write-execution.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/ui/agent-widget.ts +106 -20
  30. package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
  31. package/src/ui/ccstyle/tool-renderer.ts +9 -22
  32. package/src/ui/ccstyle/tool-result.ts +82 -186
  33. package/src/ui/conversation-blocks.ts +13 -11
  34. package/src/ui/conversation-timeline.ts +211 -122
  35. package/src/ui/conversation-viewer.ts +149 -125
  36. package/src/ui/fleet-list.ts +34 -1
  37. package/src/write-execution.ts +1 -7
  38. package/test/ui/agent-widget.test.ts +161 -0
  39. package/test/ui/conversation-diff.test.ts +1 -47
  40. package/test/ui/conversation-viewer.test.ts +181 -112
  41. package/test/ui/fleet-list.test.ts +5 -1
  42. package/test/write-execution.test.ts +0 -2
  43. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
  44. package/.pi/workflows/index.json +0 -5
@@ -51,21 +51,16 @@ export function outputLineCount(result: any): number {
51
51
  }
52
52
 
53
53
  export function countLines(text: string): number {
54
- return text.trim() ? text.trim().split("\n").filter((line) => line.trim().length > 0).length : 0;
54
+ return text.trim() ? text.trim().split("\n").filter((line) => line.trim()).length : 0;
55
55
  }
56
56
 
57
57
  /** Format arguments as stable, readable `key: value` rows for the Input rail. */
58
58
  export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
59
59
  if (args === undefined || args === null) return "";
60
- if (typeof args !== "object") {
61
- const text = sanitizeToolResultText(String(args));
62
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
60
+ if (typeof args !== "object" || Array.isArray(args)) {
61
+ const rendered = typeof args === "string" ? sanitizeToolResultText(args) : safeJson(args);
62
+ return rendered.length > maxChars ? `${rendered.slice(0, maxChars)}…` : rendered;
63
63
  }
64
- if (Array.isArray(args)) {
65
- const text = safeJson(args);
66
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
67
- }
68
-
69
64
  const preferred = [
70
65
  "path", "file_path", "command", "query", "pattern", "url", "name",
71
66
  "message", "content", "old_string", "new_string",
@@ -81,32 +76,24 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
81
76
  return li - ri;
82
77
  });
83
78
  const lines: string[] = [];
84
- for (const [rawKey, value] of entries) {
85
- const key = sanitizeToolResultText(rawKey);
79
+ for (const [key, value] of entries) {
80
+ const safeKey = sanitizeToolResultText(key);
86
81
  if (typeof value === "string") {
87
82
  const safeValue = sanitizeToolResultText(value);
88
83
  if (safeValue.includes("\n")) {
89
- lines.push(`${key}:`);
90
- for (const line of safeValue.split("\n")) lines.push(` ${line}`);
91
- } else {
92
- lines.push(`${key}: ${safeValue}`);
93
- }
94
- continue;
95
- }
96
- if (typeof value === "number" || typeof value === "boolean" || value === null) {
97
- lines.push(`${key}: ${String(value)}`);
98
- continue;
99
- }
100
- const json = safeJson(value);
101
- if (json.includes("\n")) {
102
- lines.push(`${key}:`);
103
- for (const line of json.split("\n")) lines.push(` ${line}`);
84
+ lines.push(`${safeKey}:`);
85
+ lines.push(...safeValue.split("\n").map((line) => ` ${line}`));
86
+ } else lines.push(`${safeKey}: ${safeValue}`);
104
87
  } else {
105
- lines.push(`${key}: ${json}`);
88
+ const rendered = safeJson(value);
89
+ if (rendered.includes("\n")) {
90
+ lines.push(`${safeKey}:`);
91
+ lines.push(...rendered.split("\n").map((line) => ` ${line}`));
92
+ } else lines.push(`${safeKey}: ${rendered}`);
106
93
  }
107
94
  }
108
- const text = lines.join("\n");
109
- return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
95
+ const rendered = lines.join("\n");
96
+ return rendered.length > maxChars ? `${rendered.slice(0, maxChars)}…` : rendered;
110
97
  }
111
98
 
112
99
  function hasExpandableDetail(text: string, args: unknown): boolean {
@@ -115,18 +102,8 @@ function hasExpandableDetail(text: string, args: unknown): boolean {
115
102
 
116
103
  export type ToolIoSection = "input" | "output";
117
104
 
118
- /**
119
- * Viewer-local Input/Output card. The rail and show-more rows intentionally
120
- * mirror ccstyle, but state is kept on this component so history remains
121
- * deterministic and no global terminal mouse handler is needed.
122
- */
123
- class ExpandedToolIoView implements Component {
124
- private readonly isError: boolean;
125
- private input: string;
126
- private output: string;
127
- private maxOutputLines: number;
128
- private maxInputLines: number;
129
- private theme: any;
105
+ /** Viewer-local adaptation of pi-cc's structured Input/Output tool card. */
106
+ export class ExpandedToolIoView implements Component {
130
107
  private cachedWidth: number | undefined;
131
108
  private cachedLines: string[] | undefined;
132
109
  private lineSections: Array<ToolIoSection | null> = [];
@@ -134,28 +111,16 @@ class ExpandedToolIoView implements Component {
134
111
  private showMoreRows: Partial<Record<ToolIoSection, number>> = {};
135
112
 
136
113
  constructor(
137
- input: string,
138
- output: string,
139
- isError: boolean,
140
- maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
141
- maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
142
- theme?: any,
143
- ) {
144
- this.input = input;
145
- this.output = output;
146
- this.isError = isError;
147
- this.maxOutputLines = Math.max(1, maxOutputLines);
148
- this.maxInputLines = Math.max(1, maxInputLines);
149
- this.theme = theme;
150
- }
114
+ private input: string,
115
+ private output: string,
116
+ private readonly isError: boolean,
117
+ private readonly maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
118
+ private readonly maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
119
+ private readonly theme?: any,
120
+ ) {}
151
121
 
152
- getInputBody(): string {
153
- return this.input;
154
- }
155
-
156
- getOutputBody(): string {
157
- return this.output.trim() ? this.output : "Done";
158
- }
122
+ getInputBody(): string { return this.input; }
123
+ getOutputBody(): string { return this.output.trim() ? this.output : "Done"; }
159
124
 
160
125
  setHoveredSection(section: ToolIoSection | null): void {
161
126
  if (this.hoveredSection === section) return;
@@ -163,47 +128,30 @@ class ExpandedToolIoView implements Component {
163
128
  this.invalidate();
164
129
  }
165
130
 
166
- sectionAtLine(line: number): ToolIoSection | null {
167
- return this.lineSections[line] ?? null;
168
- }
169
-
170
- showMoreLine(section: ToolIoSection): number | undefined {
171
- return this.showMoreRows[section];
172
- }
131
+ sectionAtLine(line: number): ToolIoSection | null { return this.lineSections[line] ?? null; }
132
+ showMoreLine(section: ToolIoSection): number | undefined { return this.showMoreRows[section]; }
173
133
 
174
134
  render(width: number): string[] {
175
135
  const safeWidth = toolViewportWidth(width);
176
136
  if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
177
-
178
- const bodyColor = this.isError ? "error" : "toolOutput";
137
+ const theme = this.theme;
138
+ const color = (name: string, value: string): string => typeof theme?.fg === "function" ? theme.fg(name, value) : value;
139
+ const bold = (value: string): string => typeof theme?.bold === "function" ? theme.bold(value) : value;
179
140
  const rail = " │ ";
180
141
  const contentWidth = Math.max(1, safeWidth - visibleWidth(rail));
142
+ const bodyColor = this.isError ? "error" : "toolOutput";
181
143
  const rows: string[] = [];
182
144
  this.lineSections = [];
183
145
  this.showMoreRows = {};
184
-
185
146
  const push = (line: string, section: ToolIoSection | null) => {
186
147
  rows.push(truncateToWidth(line, safeWidth, ""));
187
148
  this.lineSections.push(section);
188
149
  };
189
- // The theme is optional so the component remains safe in small/test hosts.
190
- const theme = this.theme;
191
- const color = (name: string, value: string): string => typeof theme?.fg === "function" ? theme.fg(name, value) : value;
192
- const bold = (value: string): string => typeof theme?.bold === "function" ? theme.bold(value) : value;
193
- const pushHeader = (corner: "├" | "└", title: string) => push(
194
- color("dim", ` ${corner} `) + color("accent", bold(title)),
195
- null,
196
- );
197
- const pushRail = (value: string, section: ToolIoSection, continued: boolean) => push(
198
- color("dim", continued ? rail : " ") + value,
199
- section,
200
- );
201
- const pushBlank = () => push(color("dim", " │"), null);
202
- const styleInput = (line: string): string => {
203
- const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
204
- return match
205
- ? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
206
- : color("muted", line);
150
+ const pushHeader = (corner: "├" | "└", title: string) => {
151
+ push(color("dim", ` ${corner} `) + color("accent", bold(title)), null);
152
+ };
153
+ const pushRail = (line: string, section: ToolIoSection, continued: boolean) => {
154
+ push(color("dim", continued ? rail : " ") + line, section);
207
155
  };
208
156
  const pushBody = (body: string, section: ToolIoSection, limit: number, continued: boolean, input: boolean) => {
209
157
  const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
@@ -213,20 +161,25 @@ class ExpandedToolIoView implements Component {
213
161
  }
214
162
  const sources = raw.split("\n");
215
163
  const wrapped: string[] = [];
164
+ const styleInput = (line: string): string => {
165
+ const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
166
+ return match
167
+ ? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
168
+ : color("muted", line);
169
+ };
216
170
  for (const source of sources) {
217
171
  const styled = input ? styleInput(source) : color(bodyColor, source);
218
- const parts = wrapTextWithAnsi(styled, contentWidth);
219
- wrapped.push(...(parts.length ? parts : [styled]));
172
+ const lines = wrapTextWithAnsi(styled, contentWidth);
173
+ wrapped.push(...(lines.length ? lines : [styled]));
220
174
  }
221
175
  const truncated = sources.length > limit || wrapped.length > limit;
222
176
  const visible = truncated ? wrapped.slice(0, limit) : wrapped;
223
177
  for (const line of visible) pushRail(line, section, continued);
224
- if (truncated) {
225
- const hidden = Math.max(1, wrapped.length - visible.length);
178
+ if (truncated && wrapped.length > visible.length) {
179
+ const hidden = wrapped.length - visible.length;
226
180
  const hintColor = this.hoveredSection === section ? "text" : "dim";
227
181
  pushRail(
228
- color("dim", `… +${hidden} more lines`) +
229
- color("dim", " •") + color(hintColor, ` ${showMoreHintText()}`),
182
+ color("dim", `… +${hidden} more lines`) + color("dim", " •") + color(hintColor, ` ${showMoreHintText()}`),
230
183
  section,
231
184
  continued,
232
185
  );
@@ -235,15 +188,13 @@ class ExpandedToolIoView implements Component {
235
188
  };
236
189
 
237
190
  const hasInput = this.input.trim().length > 0;
238
- const output = this.getOutputBody();
239
191
  if (hasInput) {
240
192
  pushHeader("├", "Input");
241
- pushBody(this.input, "input", this.maxInputLines, true, true);
242
- pushBlank();
193
+ pushBody(this.input, "input", Math.max(1, this.maxInputLines), true, true);
194
+ push(color("dim", " │"), null);
243
195
  }
244
196
  pushHeader("└", "Output");
245
- pushBody(output, "output", this.maxOutputLines, false, false);
246
-
197
+ pushBody(this.getOutputBody(), "output", Math.max(1, this.maxOutputLines), false, false);
247
198
  this.cachedWidth = safeWidth;
248
199
  this.cachedLines = rows;
249
200
  return rows;
@@ -253,7 +204,6 @@ class ExpandedToolIoView implements Component {
253
204
  this.cachedWidth = undefined;
254
205
  this.cachedLines = undefined;
255
206
  }
256
-
257
207
  }
258
208
 
259
209
  function unavailableComponent(reason: string, theme: any): Component {
@@ -265,34 +215,24 @@ function unavailableComponent(reason: string, theme: any): Component {
265
215
  };
266
216
  }
267
217
 
268
- function resultText(result: any): string {
269
- return rawTextFromResult(result);
270
- }
218
+ function resultText(result: any): string { return rawTextFromResult(result); }
271
219
 
272
220
  function persistedFromDetails(details: any): PersistedDiff | undefined {
273
221
  if (!details || typeof details !== "object") return undefined;
274
222
  const persisted: PersistedDiff = {};
275
223
  if (typeof details.diff === "string") persisted.diff = details.diff;
276
224
  if (typeof details.patch === "string") persisted.patch = details.patch;
277
- if (typeof details.diffUnavailableReason === "string") {
278
- persisted.diffUnavailableReason = details.diffUnavailableReason;
279
- }
225
+ if (typeof details.diffUnavailableReason === "string") persisted.diffUnavailableReason = details.diffUnavailableReason;
280
226
  return Object.keys(persisted).length ? persisted : undefined;
281
227
  }
282
228
 
283
229
  function viewerConfig(context: any): ViewerDiffConfig {
284
230
  const candidate = context?.diffConfig;
285
- if (!candidate || typeof candidate !== "object") return DEFAULT_TOOL_DISPLAY_CONFIG;
286
- return {
287
- ...DEFAULT_TOOL_DISPLAY_CONFIG,
288
- ...candidate,
289
- };
231
+ return candidate && typeof candidate === "object"
232
+ ? { ...DEFAULT_TOOL_DISPLAY_CONFIG, ...candidate }
233
+ : DEFAULT_TOOL_DISPLAY_CONFIG;
290
234
  }
291
235
 
292
- /**
293
- * Rich edit/write rendering for the conversation viewer. Durable details are
294
- * passed through before any live/ephemeral context so history is deterministic.
295
- */
296
236
  export function renderRichToolResult(
297
237
  toolName: string,
298
238
  result: any,
@@ -303,61 +243,35 @@ export function renderRichToolResult(
303
243
  if (options?.isPartial || options?.isError || context?.isError) return undefined;
304
244
  const expanded = options?.expanded === true || context?.expanded === true;
305
245
  const args = context?.args ?? options?.args;
306
- const filePath = args?.file_path ?? args?.path;
307
246
  const details = result?.details;
308
247
  const persistedDiff = context?.persistedDiff ?? persistedFromDetails(details);
309
248
  const fileExistedBeforeWrite = typeof context?.fileExistedBeforeWrite === "boolean"
310
249
  ? context.fileExistedBeforeWrite
311
- : typeof details?.fileExistedBeforeWrite === "boolean"
312
- ? details.fileExistedBeforeWrite
313
- : undefined;
250
+ : typeof details?.fileExistedBeforeWrite === "boolean" ? details.fileExistedBeforeWrite : undefined;
314
251
 
315
252
  if (toolName === "edit") {
316
- return renderEditDiffResult(
317
- details,
318
- { expanded, filePath, persistedDiff, isHovered: options?.isHovered, invalidate: context?.invalidate },
319
- viewerConfig(context),
320
- theme,
321
- resultText(result),
322
- );
253
+ return renderEditDiffResult(details, {
254
+ expanded, filePath: args?.file_path ?? args?.path, persistedDiff,
255
+ isHovered: options?.isHovered, invalidate: context?.invalidate,
256
+ }, viewerConfig(context), theme, resultText(result));
323
257
  }
324
258
  if (toolName !== "write") return undefined;
325
259
  if (persistedDiff?.diffUnavailableReason && !persistedDiff.diff && !persistedDiff.patch) {
326
260
  return unavailableComponent(persistedDiff.diffUnavailableReason, theme);
327
261
  }
328
-
329
262
  const content = typeof args?.content === "string" ? args.content : undefined;
330
- if (!content && !persistedDiff?.diff && !persistedDiff?.patch) {
263
+ if (typeof content !== "string" && !persistedDiff?.diff && !persistedDiff?.patch) {
331
264
  return unavailableComponent("execution metadata is unavailable", theme);
332
265
  }
333
- return renderWriteDiffResult(
334
- content,
335
- {
336
- expanded,
337
- filePath,
338
- persistedDiff,
339
- previousContent: context?.previousContent,
340
- fileExistedBeforeWrite,
341
- isHovered: options?.isHovered,
342
- invalidate: context?.invalidate,
343
- },
344
- viewerConfig(context),
345
- theme,
346
- resultText(result),
347
- );
348
- }
349
-
350
- function renderCallSummary(toolName: string, args: any, theme: any): string {
351
- const title = toolName.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ");
352
- const value = args?.path ?? args?.file_path ?? args?.command ?? args?.query ?? args?.name;
353
- const summary = value === undefined ? title : `${title} ${oneLine(value)}`;
354
- return theme.fg("toolTitle", summary);
355
- }
356
-
357
- function settledIcon(context: any): string {
358
- if (context?.isError) return "✗";
359
- if (context?.isPartial || context?.executionStarted) return "●";
360
- return "✓";
266
+ return renderWriteDiffResult(content, {
267
+ expanded,
268
+ filePath: args?.file_path ?? args?.path,
269
+ persistedDiff,
270
+ previousContent: context?.previousContent,
271
+ fileExistedBeforeWrite,
272
+ isHovered: options?.isHovered,
273
+ invalidate: context?.invalidate,
274
+ }, viewerConfig(context), theme, resultText(result));
361
275
  }
362
276
 
363
277
  function renderFallbackResult(result: any, options: any, theme: any, context: any): Component {
@@ -365,49 +279,31 @@ function renderFallbackResult(result: any, options: any, theme: any, context: an
365
279
  const expanded = options?.expanded === true || context?.expanded === true;
366
280
  const maxChars = Number.isFinite(context?.maxChars) ? Math.max(0, Math.floor(context.maxChars)) : 16_384;
367
281
  const text = textFromResult(result, expanded, maxChars);
368
- if (expanded && (text.trim() || formatToolInputArgs(context?.args, maxChars).trim())) {
282
+ const input = formatToolInputArgs(context?.args, maxChars);
283
+ if (expanded && (text.trim() || input.trim())) {
369
284
  const maxLines = Number.isFinite(context?.diffConfig?.expandedPreviewMaxLines)
370
285
  ? Math.max(1, Math.floor(context.diffConfig.expandedPreviewMaxLines))
371
286
  : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
372
- return new ExpandedToolIoView(
373
- formatToolInputArgs(context?.args, maxChars),
374
- text,
375
- Boolean(error),
376
- maxLines,
377
- maxLines,
378
- theme,
379
- );
287
+ return new ExpandedToolIoView(input, text, Boolean(error), maxLines, maxLines, theme);
380
288
  }
381
- const lines = text ? text.split("\n") : [];
382
- const count = lines.length;
289
+ const count = text ? text.split("\n").length : 0;
383
290
  const summary = error ? oneLine(text) || "Failed" : count ? `${count} ${count === 1 ? "line" : "lines"} returned` : "Done";
384
291
  const expandable = !expanded && hasExpandableDetail(text, context?.args);
385
292
  return {
386
293
  render(width: number): string[] {
387
294
  const hovered = options?.isHovered?.() ?? context?.isHovered?.() ?? false;
388
- const clickLabel = showMoreHintText();
389
- const hint = expandable ? ` • ${clickLabel}` : "";
390
- const color = error ? "error" : "muted";
295
+ const hint = expandable ? ` • ${showMoreHintText()}` : "";
391
296
  const line = expandable && hovered
392
- ? theme.fg(color, ` ↳ ${summary} • `) + theme.fg("text", clickLabel)
393
- : theme.fg(color, ` ↳ ${summary}${hint}`);
297
+ ? theme.fg(error ? "error" : "muted", ` ↳ ${summary} • `) + theme.fg("text", showMoreHintText())
298
+ : theme.fg(error ? "error" : "muted", ` ↳ ${summary}${hint}`);
394
299
  return [truncateToWidth(line, toolViewportWidth(width), "")];
395
300
  },
396
301
  invalidate() {},
397
302
  };
398
303
  }
399
304
 
400
- export function createViewerCcstyleResult(
401
- toolName: string,
402
- result: any,
403
- options: any,
404
- theme: any,
405
- context: any,
406
- ): Component {
407
- return (
408
- renderRichToolResult(toolName, result, options, theme, context) ??
409
- renderFallbackResult(result, options, theme, context)
410
- );
305
+ export function createViewerCcstyleResult(toolName: string, result: any, options: any, theme: any, context: any): Component {
306
+ return renderRichToolResult(toolName, result, options, theme, context) ?? renderFallbackResult(result, options, theme, context);
411
307
  }
412
308
 
413
- export { ExpandedToolIoView };
309
+ export type { DisplayConfigInput };
@@ -300,23 +300,25 @@ export function formatConversationMessages(
300
300
  const blocks: ConversationBlock[] = [];
301
301
  const usedBlockIds = new Set<string>();
302
302
  const callsById = new Map<string, ConversationBlock>();
303
- const callsByName = new Map<string, ConversationBlock[]>();
303
+ const callsByName = new Map<string, { blocks: ConversationBlock[]; next: number }>();
304
304
 
305
305
  const registerCall = (name: string, block: ConversationBlock) => {
306
306
  callsById.set(block.toolCallId!, block);
307
307
  const nameKey = name.toLowerCase();
308
- const candidates = callsByName.get(nameKey) ?? [];
309
- candidates.push(block);
310
- callsByName.set(nameKey, candidates);
308
+ const queue = callsByName.get(nameKey) ?? { blocks: [], next: 0 };
309
+ queue.blocks.push(block);
310
+ callsByName.set(nameKey, queue);
311
311
  };
312
312
  const unregisterCall = (block: ConversationBlock) => {
313
+ // Calls remain in their name queue as tombstones. Advancing its cursor is
314
+ // amortized O(1), unlike removing from the middle of a same-name array.
313
315
  if (block.toolCallId) callsById.delete(block.toolCallId);
314
- const nameKey = (block.toolName ?? "tool").toLowerCase();
315
- const candidates = callsByName.get(nameKey);
316
- if (!candidates) return;
317
- const index = candidates.indexOf(block);
318
- if (index >= 0) candidates.splice(index, 1);
319
- if (candidates.length === 0) callsByName.delete(nameKey);
316
+ };
317
+ const nextCallByName = (name: string): ConversationBlock | undefined => {
318
+ const queue = callsByName.get(name.toLowerCase());
319
+ if (!queue) return undefined;
320
+ while (queue.next < queue.blocks.length && queue.blocks[queue.next]?.toolStatus !== "pending") queue.next++;
321
+ return queue.blocks[queue.next];
320
322
  };
321
323
 
322
324
  for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
@@ -368,7 +370,7 @@ export function formatConversationMessages(
368
370
  const direct = result.toolCallId ? callsById.get(result.toolCallId) : undefined;
369
371
  let match = direct;
370
372
  if (!match && result.toolName) {
371
- match = callsByName.get(result.toolName.toLowerCase())?.[0];
373
+ match = nextCallByName(result.toolName);
372
374
  }
373
375
  if (match) {
374
376
  applyToolResult(match, result, maxToolResultChars);