@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
@@ -29,10 +29,15 @@ export interface TimelineRenderLine {
29
29
  railable?: boolean;
30
30
  }
31
31
 
32
- export interface TimelineToolSpan {
33
- toolCallId: string;
32
+ export interface TimelineBlockSpan {
33
+ /** Index into the snapshot's messageBlocks array, not the source block array. */
34
+ blockIndex: number;
34
35
  startLine: number;
35
36
  height: number;
37
+ }
38
+
39
+ export interface TimelineToolSpan extends TimelineBlockSpan {
40
+ toolCallId: string;
36
41
  /** The only row that opens a collapsed card with a single click. */
37
42
  clickStartLine?: number;
38
43
  clickHeight?: number;
@@ -43,13 +48,32 @@ export interface ConversationTimelineSnapshot {
43
48
  messageStarts: number[];
44
49
  messageBlocks: ConversationBlock[];
45
50
  interactiveToolIds: string[];
51
+ blockSpans: TimelineBlockSpan[];
46
52
  toolSpans: TimelineToolSpan[];
47
53
  }
48
54
 
55
+ export interface ConversationTimelineMouseResult {
56
+ handled?: boolean;
57
+ capture?: boolean;
58
+ render?: boolean;
59
+ focusedBlockIndex?: number;
60
+ }
61
+
62
+ export interface ConversationTimelineLineRange {
63
+ startLine: number;
64
+ height: number;
65
+ }
66
+
67
+ export interface ConversationTimelineChange {
68
+ kind: "content" | "interaction";
69
+ /** Lines patched in-place for an interaction-only change such as hover. */
70
+ changedRanges?: readonly ConversationTimelineLineRange[];
71
+ }
72
+
49
73
  export interface ConversationTimelineOptions {
50
74
  cwd?: string;
51
75
  record?: AgentRecord;
52
- onChange?: () => void;
76
+ onChange?: (change: ConversationTimelineChange) => void;
53
77
  }
54
78
 
55
79
  function safeWidth(width: number): number {
@@ -81,7 +105,7 @@ function fallbackToolLines(block: ConversationBlock, width: number, expanded = f
81
105
  const clipped = truncateToWidth(line, width, "");
82
106
  if (!hovered || expanded || !theme) return clipped;
83
107
  const plain = stripAnsi(clipped);
84
- const marker = plain.includes(showMoreHintText()) ? showMoreHintText() : plain.includes("Enter to expand") ? "Enter to expand" : "";
108
+ const marker = plain.includes(showMoreHintText()) ? showMoreHintText() : "";
85
109
  if (!marker) return clipped;
86
110
  const markerIndex = plain.indexOf(marker);
87
111
  return theme.fg("dim", plain.slice(0, markerIndex)) + theme.fg("text", marker) + theme.fg("dim", plain.slice(markerIndex + marker.length));
@@ -144,8 +168,8 @@ export class ConversationTimeline implements Component {
144
168
  private expandedTools = new Set<string>();
145
169
  private hoveredToolCallId: string | undefined;
146
170
  private hoveredIoView: any;
171
+ private hoveredIoToolCallId: string | undefined;
147
172
  private hoveredIoSection: "input" | "output" | null = null;
148
- /** The last tool clicked; used to make only clickCount=2 collapse it. */
149
173
  private lastClickedToolCallId: string | undefined;
150
174
  private lastClickedPoint: { x: number; y: number } | undefined;
151
175
  private toolComponents = new Map<string, Component & { handleMouse?: (event: TuiMouseEvent) => unknown }>();
@@ -154,6 +178,7 @@ export class ConversationTimeline implements Component {
154
178
  messageStarts: [],
155
179
  messageBlocks: [],
156
180
  interactiveToolIds: [],
181
+ blockSpans: [],
157
182
  toolSpans: [],
158
183
  };
159
184
  private lastWidth = 0;
@@ -169,15 +194,12 @@ export class ConversationTimeline implements Component {
169
194
  const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
170
195
  if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
171
196
  if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
172
- if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
173
- this.hoveredIoView.setHoveredSection(null);
197
+ if (this.hoveredIoToolCallId && !ids.has(this.hoveredIoToolCallId)) {
198
+ this.hoveredIoView?.setHoveredSection?.(null);
174
199
  this.hoveredIoView = undefined;
200
+ this.hoveredIoToolCallId = undefined;
175
201
  this.hoveredIoSection = null;
176
202
  }
177
- if (this.lastClickedToolCallId && !ids.has(this.lastClickedToolCallId)) {
178
- this.lastClickedToolCallId = undefined;
179
- this.lastClickedPoint = undefined;
180
- }
181
203
  for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
182
204
  for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
183
205
  this.invalidate();
@@ -189,13 +211,10 @@ export class ConversationTimeline implements Component {
189
211
  if (!show) {
190
212
  this.focusedToolCallId = undefined;
191
213
  this.hoveredToolCallId = undefined;
192
- if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
193
- this.hoveredIoView.setHoveredSection(null);
194
- }
214
+ this.hoveredIoView?.setHoveredSection?.(null);
195
215
  this.hoveredIoView = undefined;
216
+ this.hoveredIoToolCallId = undefined;
196
217
  this.hoveredIoSection = null;
197
- this.lastClickedToolCallId = undefined;
198
- this.lastClickedPoint = undefined;
199
218
  }
200
219
  this.invalidate();
201
220
  }
@@ -205,18 +224,16 @@ export class ConversationTimeline implements Component {
205
224
  }
206
225
 
207
226
  setFocusedToolCallId(id: string | undefined): void {
227
+ // Focus changes are view state. The rendered rows do not change, so keep
228
+ // the snapshot and its Markdown/tool output cache intact.
229
+ if (this.focusedToolCallId === id) return;
208
230
  this.focusedToolCallId = id;
209
- this.invalidate();
210
231
  }
211
232
 
212
233
  getFocusedToolCallId(): string | undefined {
213
234
  return this.focusedToolCallId;
214
235
  }
215
236
 
216
- getHoveredToolCallId(): string | undefined {
217
- return this.hoveredToolCallId;
218
- }
219
-
220
237
  getSnapshot(): ConversationTimelineSnapshot {
221
238
  if (this.lastWidth <= 0) this.render(80);
222
239
  return this.snapshot;
@@ -230,8 +247,7 @@ export class ConversationTimeline implements Component {
230
247
  ? direction > 0 ? 0 : ids.length - 1
231
248
  : (current + direction + ids.length) % ids.length;
232
249
  this.focusedToolCallId = ids[next];
233
- this.invalidate();
234
- this.options.onChange?.();
250
+ this.options.onChange?.({ kind: "interaction" });
235
251
  return true;
236
252
  }
237
253
 
@@ -241,7 +257,7 @@ export class ConversationTimeline implements Component {
241
257
  if (this.expandedTools.has(id)) this.expandedTools.delete(id);
242
258
  else this.expandedTools.add(id);
243
259
  this.invalidate();
244
- this.options.onChange?.();
260
+ this.options.onChange?.({ kind: "content" });
245
261
  return true;
246
262
  }
247
263
 
@@ -251,20 +267,24 @@ export class ConversationTimeline implements Component {
251
267
 
252
268
  render(width: number): string[] {
253
269
  const requestedWidth = safeWidth(width);
270
+ if (this.lastWidth === requestedWidth) return this.snapshot.lines.map((line) => line.text);
271
+
254
272
  const lines: TimelineRenderLine[] = [];
255
273
  const messageStarts: number[] = [];
256
274
  const messageBlocks: ConversationBlock[] = [];
257
275
  const interactiveToolIds: string[] = [];
276
+ const blockSpans: TimelineBlockSpan[] = [];
258
277
  const toolSpans: TimelineToolSpan[] = [];
259
- const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
260
278
 
261
- if (visibleBlocks.length === 0) {
279
+ if (this.blocks.length === 0 || (!this.showTools && this.blocks.every((block) => block.kind === "tool"))) {
262
280
  const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
263
281
  lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
264
282
  }
265
283
 
266
- for (const block of visibleBlocks) {
267
- const blockIndex = this.blocks.indexOf(block);
284
+ for (let sourceBlockIndex = 0; sourceBlockIndex < this.blocks.length; sourceBlockIndex++) {
285
+ const block = this.blocks[sourceBlockIndex]!;
286
+ if (!this.showTools && block.kind === "tool") continue;
287
+ const blockIndex = sourceBlockIndex;
268
288
  if (lines.length > 0) {
269
289
  const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
270
290
  lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
@@ -272,6 +292,7 @@ export class ConversationTimeline implements Component {
272
292
 
273
293
  const hasRoleHeader = block.role === "user" || block.role === "assistant";
274
294
  const blockStartLine = lines.length;
295
+ const snapshotBlockIndex = messageBlocks.length;
275
296
  messageStarts.push(blockStartLine);
276
297
  messageBlocks.push(block);
277
298
  if (hasRoleHeader) {
@@ -282,60 +303,22 @@ export class ConversationTimeline implements Component {
282
303
  if (block.kind === "tool") {
283
304
  const id = block.toolCallId;
284
305
  if (id) interactiveToolIds.push(id);
285
- let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
286
- if (id) {
287
- const existing = this.toolComponents.get(id);
288
- if (existing) {
289
- applyToolComponentState(existing, block, this.expandedTools.has(id));
290
- component = existing;
291
- } else {
292
- component = createToolComponent(
293
- block,
294
- this.tui,
295
- this.options.cwd ?? process.cwd(),
296
- this.expandedTools.has(id),
297
- () => this.hoveredToolCallId === id,
298
- ) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
299
- if (component) this.toolComponents.set(id, component);
300
- }
301
- }
302
- let rendered: string[];
303
- try {
304
- rendered = component?.render(requestedWidth) ?? fallbackToolLines(
305
- block,
306
- requestedWidth,
307
- id ? this.expandedTools.has(id) : false,
308
- id ? this.hoveredToolCallId === id : false,
309
- this.theme,
310
- );
311
- } catch {
312
- // Pi's interactive theme is a capability, not a viewer dependency;
313
- // test hosts and older Pi versions can lack its initialized theme.
314
- rendered = fallbackToolLines(
315
- block,
316
- requestedWidth,
317
- id ? this.expandedTools.has(id) : false,
318
- id ? this.hoveredToolCallId === id : false,
319
- this.theme,
320
- );
321
- }
306
+ const { rendered, hintLine } = this.renderToolLines(block, requestedWidth);
322
307
  for (const line of rendered) {
323
308
  lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
324
309
  }
325
310
  if (id) {
326
- const startLine = blockStartLine;
327
- const hintLine = !this.expandedTools.has(id)
328
- ? rendered.findIndex((line) => {
329
- const plain = stripAnsi(line);
330
- return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
331
- })
332
- : -1;
311
+ const blockHeight = lines.length - blockStartLine;
312
+ blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: blockHeight });
333
313
  toolSpans.push({
314
+ blockIndex: snapshotBlockIndex,
334
315
  toolCallId: id,
335
- startLine,
336
- height: lines.length - startLine,
337
- ...(hintLine >= 0 ? { clickStartLine: startLine + (hasRoleHeader ? 1 : 0) + hintLine, clickHeight: 1 } : {}),
316
+ startLine: blockStartLine,
317
+ height: blockHeight,
318
+ ...(hintLine >= 0 ? { clickStartLine: blockStartLine + (hasRoleHeader ? 1 : 0) + hintLine, clickHeight: 1 } : {}),
338
319
  });
320
+ } else {
321
+ blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: lines.length - blockStartLine });
339
322
  }
340
323
  continue;
341
324
  }
@@ -358,14 +341,88 @@ export class ConversationTimeline implements Component {
358
341
  }
359
342
  }
360
343
  }
344
+ blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: lines.length - blockStartLine });
361
345
  }
362
346
 
363
347
  this.lastWidth = requestedWidth;
364
- this.snapshot = { lines, messageStarts, messageBlocks, interactiveToolIds, toolSpans };
348
+ this.snapshot = { lines, messageStarts, messageBlocks, interactiveToolIds, blockSpans, toolSpans };
365
349
  return lines.map((line) => line.text);
366
350
  }
367
351
 
368
- handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
352
+ private renderToolLines(block: ConversationBlock, width: number): { rendered: string[]; hintLine: number } {
353
+ const id = block.toolCallId;
354
+ let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
355
+ if (id) {
356
+ const existing = this.toolComponents.get(id);
357
+ if (existing) {
358
+ applyToolComponentState(existing, block, this.expandedTools.has(id));
359
+ component = existing;
360
+ } else {
361
+ component = createToolComponent(
362
+ block,
363
+ this.tui,
364
+ this.options.cwd ?? process.cwd(),
365
+ this.expandedTools.has(id),
366
+ () => this.hoveredToolCallId === id,
367
+ ) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
368
+ if (component) this.toolComponents.set(id, component);
369
+ }
370
+ }
371
+
372
+ let rendered: string[];
373
+ try {
374
+ rendered = component?.render(width) ?? fallbackToolLines(
375
+ block,
376
+ width,
377
+ id ? this.expandedTools.has(id) : false,
378
+ id ? this.hoveredToolCallId === id : false,
379
+ this.theme,
380
+ );
381
+ } catch {
382
+ rendered = fallbackToolLines(
383
+ block,
384
+ width,
385
+ id ? this.expandedTools.has(id) : false,
386
+ id ? this.hoveredToolCallId === id : false,
387
+ this.theme,
388
+ );
389
+ }
390
+ const hintLine = id && !this.expandedTools.has(id)
391
+ ? rendered.findIndex((line) => {
392
+ const plain = stripAnsi(line);
393
+ return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
394
+ })
395
+ : -1;
396
+ return { rendered, hintLine };
397
+ }
398
+
399
+ /**
400
+ * Hover only changes styling in a tool card. Re-render those rows in place
401
+ * and leave the Markdown rows and their spans untouched.
402
+ */
403
+ private refreshToolSpan(span: TimelineToolSpan): boolean {
404
+ const block = this.snapshot.messageBlocks[span.blockIndex];
405
+ if (!block || block.kind !== "tool") return false;
406
+ const { rendered, hintLine } = this.renderToolLines(block, this.lastWidth);
407
+ if (rendered.length + (block.role === "user" || block.role === "assistant" ? 1 : 0) !== span.height) return false;
408
+
409
+ const headerOffset = block.role === "user" || block.role === "assistant" ? 1 : 0;
410
+ const sourceBlockIndex = this.snapshot.lines[span.startLine]?.blockIndex ?? -1;
411
+ for (let index = 0; index < rendered.length; index++) {
412
+ const line = rendered[index];
413
+ this.snapshot.lines[span.startLine + headerOffset + index] = {
414
+ text: truncateToWidth(line, this.lastWidth),
415
+ plain: stripAnsi(line),
416
+ blockIndex: sourceBlockIndex,
417
+ railable: true,
418
+ };
419
+ }
420
+ span.clickStartLine = hintLine >= 0 ? span.startLine + headerOffset + hintLine : undefined;
421
+ span.clickHeight = hintLine >= 0 ? 1 : undefined;
422
+ return true;
423
+ }
424
+
425
+ handleMouse(event: TuiMouseEvent): ConversationTimelineMouseResult | undefined {
369
426
  if (event.type === "move") {
370
427
  const hovered = this.snapshot.toolSpans.find((span) =>
371
428
  span.clickStartLine !== undefined &&
@@ -389,78 +446,110 @@ export class ConversationTimeline implements Component {
389
446
  }
390
447
  }
391
448
  }
392
- const unchanged = hovered === this.hoveredToolCallId &&
393
- nextIoView === this.hoveredIoView && nextIoSection === this.hoveredIoSection;
449
+ const unchanged = hovered === this.hoveredToolCallId && nextIoView === this.hoveredIoView && nextIoSection === this.hoveredIoSection;
394
450
  if (unchanged) return undefined;
395
451
  if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
396
452
  this.hoveredIoView.setHoveredSection(null);
397
453
  }
454
+ const previousHoveredToolCallId = this.hoveredToolCallId;
455
+ const previousIoToolCallId = this.hoveredIoToolCallId;
456
+ const nextIoToolCallId = nextIoView ? fullSpan?.toolCallId : undefined;
398
457
  this.hoveredToolCallId = hovered;
399
458
  this.hoveredIoView = nextIoView;
459
+ this.hoveredIoToolCallId = nextIoToolCallId;
400
460
  this.hoveredIoSection = nextIoSection;
401
461
  if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
402
- this.invalidate();
403
- this.options.onChange?.();
462
+
463
+ const changedIds = new Set(
464
+ [previousHoveredToolCallId, hovered, previousIoToolCallId, nextIoToolCallId]
465
+ .filter((id): id is string => id !== undefined),
466
+ );
467
+ const changedRanges: ConversationTimelineLineRange[] = [];
468
+ let patched = true;
469
+ for (const id of changedIds) {
470
+ const span = this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === id);
471
+ if (!span || !this.refreshToolSpan(span)) {
472
+ patched = false;
473
+ break;
474
+ }
475
+ changedRanges.push({ startLine: span.startLine, height: span.height });
476
+ }
477
+ if (!patched) {
478
+ this.invalidate();
479
+ this.options.onChange?.({ kind: "content" });
480
+ } else {
481
+ this.options.onChange?.({ kind: "interaction", changedRanges });
482
+ }
404
483
  return { handled: true, render: true };
405
484
  }
406
485
 
407
- // The TUI sends a click only when a component claims the preceding press.
408
- // Claim only the collapsed hint (or the last expanded card's hit area for
409
- // a possible second click); never change expansion on press or release.
410
486
  if (event.type !== "click" && event.type !== "press") return undefined;
411
487
  if (event.button !== "left") return undefined;
412
- const clickCount = event.clickCount ?? 1;
488
+
489
+ // Keep the hint ahead of the full tool span. Its row has a distinct action.
413
490
  const hintSpan = this.snapshot.toolSpans.find((candidate) =>
414
491
  candidate.clickStartLine !== undefined &&
415
492
  event.y >= candidate.clickStartLine &&
416
493
  event.y < candidate.clickStartLine + (candidate.clickHeight ?? 1),
417
494
  );
418
495
  const fullSpan = this.snapshot.toolSpans.find((candidate) =>
419
- event.y >= candidate.startLine &&
420
- event.y < candidate.startLine + candidate.height,
496
+ event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height,
421
497
  );
422
- const lastSpan = this.lastClickedToolCallId
423
- ? this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === this.lastClickedToolCallId)
424
- : undefined;
425
- const sameLastPoint = this.lastClickedPoint !== undefined
426
- && event.screenX === this.lastClickedPoint.x
427
- && event.screenY === this.lastClickedPoint.y;
428
- const span = event.type === "click" && clickCount === 2 && lastSpan
429
- ? lastSpan
430
- : hintSpan ?? (sameLastPoint ? lastSpan : undefined);
431
- if (!span) return undefined;
498
+ const blockSpan = this.snapshot.blockSpans.find((candidate) =>
499
+ event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height,
500
+ );
501
+ const target = hintSpan ?? fullSpan ?? blockSpan;
502
+ if (!target) return undefined;
432
503
  if (event.type === "press") return { handled: true, capture: true, render: false };
433
504
 
434
- const previousClickedToolCallId = this.lastClickedToolCallId;
435
- this.focusedToolCallId = span.toolCallId;
436
- this.lastClickedToolCallId = span.toolCallId;
437
- this.lastClickedPoint = { x: event.screenX, y: event.screenY };
438
- const isExpanded = this.expandedTools.has(span.toolCallId);
439
- const shouldCollapse = isExpanded && clickCount === 2 && span.toolCallId === previousClickedToolCallId;
440
- if (!isExpanded && hintSpan?.toolCallId === span.toolCallId) {
441
- this.expandedTools.add(span.toolCallId);
442
- } else if (shouldCollapse) {
443
- this.expandedTools.delete(span.toolCallId);
444
- } else if (!isExpanded) {
445
- return { handled: true, render: false };
505
+ if (hintSpan || fullSpan) {
506
+ const span = hintSpan ?? fullSpan!;
507
+ const clickCount = event.clickCount ?? 1;
508
+ const previousClickedToolCallId = this.lastClickedToolCallId;
509
+ const lastSpan = this.lastClickedToolCallId
510
+ ? this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === this.lastClickedToolCallId)
511
+ : undefined;
512
+ const sameLastPoint = this.lastClickedPoint !== undefined &&
513
+ event.screenX === this.lastClickedPoint.x && event.screenY === this.lastClickedPoint.y;
514
+ const sameLastSpan = sameLastPoint && span.toolCallId === lastSpan?.toolCallId;
515
+ const isExpanded = this.expandedTools.has(span.toolCallId);
516
+ const shouldExpand = !isExpanded && hintSpan?.toolCallId === span.toolCallId;
517
+ const shouldCollapse = isExpanded && clickCount === 2 && sameLastSpan && span.toolCallId === previousClickedToolCallId;
518
+
519
+ this.focusedToolCallId = span.toolCallId;
520
+ this.lastClickedToolCallId = span.toolCallId;
521
+ this.lastClickedPoint = { x: event.screenX, y: event.screenY };
522
+ if (shouldExpand) this.expandedTools.add(span.toolCallId);
523
+ else if (shouldCollapse) this.expandedTools.delete(span.toolCallId);
524
+
525
+ if (shouldExpand || shouldCollapse) {
526
+ const component = this.toolComponents.get(span.toolCallId);
527
+ try {
528
+ component?.handleMouse?.({ ...event, y: event.y - span.startLine, height: span.height });
529
+ } catch {
530
+ // A missing/older Pi mouse implementation must not break the viewer.
531
+ }
532
+ }
533
+ if (shouldExpand || shouldCollapse) {
534
+ this.invalidate();
535
+ this.options.onChange?.({ kind: "content" });
536
+ } else {
537
+ this.options.onChange?.({ kind: "interaction" });
538
+ }
539
+ return { handled: true, render: shouldExpand || shouldCollapse, focusedBlockIndex: span.blockIndex };
446
540
  }
447
541
 
448
- const component = this.toolComponents.get(span.toolCallId);
449
- try {
450
- // Keep Pi's normalized mouse contract in the component tree. The local
451
- // role header occupies the first row; ToolExecutionComponent receives
452
- // the remaining card-relative coordinate.
453
- component?.handleMouse?.({ ...event, y: event.y - span.startLine, height: span.height });
454
- } catch {
455
- // A missing/older Pi mouse implementation must not break the viewer.
456
- }
457
- this.invalidate();
458
- this.options.onChange?.();
459
- return { handled: true, render: true };
542
+ // A message click intentionally drops any stale tool focus so Enter cannot
543
+ // toggle a tool that is no longer the selected block.
544
+ this.focusedToolCallId = undefined;
545
+ this.lastClickedToolCallId = undefined;
546
+ this.lastClickedPoint = undefined;
547
+ this.options.onChange?.({ kind: "interaction" });
548
+ return { handled: true, render: true, focusedBlockIndex: target.blockIndex };
460
549
  }
461
550
 
462
551
  invalidate(): void {
463
552
  this.lastWidth = 0;
464
- this.snapshot = { lines: [], messageStarts: [], messageBlocks: [], interactiveToolIds: [], toolSpans: [] };
553
+ this.snapshot = { lines: [], messageStarts: [], messageBlocks: [], interactiveToolIds: [], blockSpans: [], toolSpans: [] };
465
554
  }
466
555
  }