@north-light/crouter 0.3.169 → 0.3.171

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 (122) hide show
  1. package/dist/build-root.d.ts +1 -16
  2. package/dist/build-root.js +1 -25
  3. package/dist/builtin-memory/insights/capture.md +66 -0
  4. package/dist/builtin-memory/insights/init.md +45 -0
  5. package/dist/builtin-memory/internal/plugins.md +12 -11
  6. package/dist/cli.js +2 -6
  7. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
  8. package/dist/clients/attach/__tests__/context-message.test.js +50 -3
  9. package/dist/clients/attach/__tests__/diagram.test.js +1 -1
  10. package/dist/clients/attach/chrome/header.d.ts +10 -0
  11. package/dist/clients/attach/chrome/header.js +35 -0
  12. package/dist/clients/attach/chrome/status-line.d.ts +3 -0
  13. package/dist/clients/attach/chrome/status-line.js +5 -0
  14. package/dist/clients/attach/chrome/widgets.js +7 -18
  15. package/dist/clients/attach/input/controller.d.ts +10 -0
  16. package/dist/clients/attach/input/controller.js +3 -0
  17. package/dist/clients/attach/overlays/help.d.ts +33 -0
  18. package/dist/clients/attach/overlays/help.js +204 -0
  19. package/dist/clients/attach/render/chat-view.d.ts +81 -9
  20. package/dist/clients/attach/render/chat-view.js +434 -90
  21. package/dist/clients/attach/render/context-message.js +3 -3
  22. package/dist/clients/attach/render/diagram.js +2 -2
  23. package/dist/clients/attach/render/measured-container.d.ts +94 -0
  24. package/dist/clients/attach/render/measured-container.js +307 -0
  25. package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
  26. package/dist/clients/attach/render/transcript-copy.js +81 -0
  27. package/dist/clients/attach/render/viewport.d.ts +29 -0
  28. package/dist/clients/attach/render/viewport.js +160 -0
  29. package/dist/clients/attach/session/bindings.d.ts +5 -1
  30. package/dist/clients/attach/session/bindings.js +16 -0
  31. package/dist/clients/attach/session/context.d.ts +5 -3
  32. package/dist/clients/attach/session/frame.d.ts +34 -0
  33. package/dist/clients/attach/session/frame.js +173 -0
  34. package/dist/clients/attach/session/frames.d.ts +2 -0
  35. package/dist/clients/attach/session/frames.js +3 -0
  36. package/dist/clients/attach/session/input-wiring.d.ts +10 -4
  37. package/dist/clients/attach/session/input-wiring.js +13 -13
  38. package/dist/clients/attach/session/keys.d.ts +4 -1
  39. package/dist/clients/attach/session/keys.js +9 -4
  40. package/dist/clients/attach/session/layout.d.ts +10 -8
  41. package/dist/clients/attach/session/layout.js +32 -31
  42. package/dist/clients/attach/session/mouse.d.ts +15 -0
  43. package/dist/clients/attach/session/mouse.js +61 -0
  44. package/dist/clients/attach/slash/dispatch.d.ts +10 -0
  45. package/dist/clients/attach/slash/dispatch.js +35 -2
  46. package/dist/clients/attach/viewer.js +605 -581
  47. package/dist/clients/web/dev-server.d.ts +4 -1
  48. package/dist/clients/web/dev-server.js +8 -19
  49. package/dist/clients/web/web-cmd.js +1 -1
  50. package/dist/commands/human/prompts.js +16 -6
  51. package/dist/commands/pkg/plugin-manage.js +143 -68
  52. package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
  53. package/dist/commands/sys/setup-core.js +21 -16
  54. package/dist/core/__tests__/human-deliver.test.js +62 -2
  55. package/dist/core/clipboard-text.d.ts +8 -0
  56. package/dist/core/clipboard-text.js +18 -0
  57. package/dist/core/command-manifests/registry.d.ts +1 -3
  58. package/dist/core/command-plugins/bundle.d.ts +29 -0
  59. package/dist/core/command-plugins/bundle.js +198 -0
  60. package/dist/core/command-plugins/discovery.d.ts +0 -8
  61. package/dist/core/command-plugins/discovery.js +2 -39
  62. package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
  63. package/dist/core/command-plugins/transport/http-fetch.js +17 -95
  64. package/dist/core/command.d.ts +9 -13
  65. package/dist/core/command.js +12 -17
  66. package/dist/core/config.js +2 -1
  67. package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
  68. package/dist/core/keybindings/attach-control.d.ts +3 -0
  69. package/dist/core/keybindings/attach-control.js +1 -0
  70. package/dist/core/keybindings/catalog.d.ts +2 -2
  71. package/dist/core/keybindings/catalog.js +8 -0
  72. package/dist/core/runtime/broker-protocol.d.ts +13 -1
  73. package/dist/core/runtime/broker.js +213 -5
  74. package/dist/core/runtime/canvas-extensions.d.ts +3 -0
  75. package/dist/core/runtime/canvas-extensions.js +3 -0
  76. package/dist/core/runtime/front-door.js +4 -4
  77. package/dist/core/runtime/kickoff.d.ts +1 -5
  78. package/dist/core/runtime/kickoff.js +1 -5
  79. package/dist/core/runtime/naming.d.ts +7 -0
  80. package/dist/core/runtime/naming.js +5 -2
  81. package/dist/core/runtime/node-read.js +15 -0
  82. package/dist/core/runtime/package-health.d.ts +6 -21
  83. package/dist/core/runtime/package-health.js +12 -123
  84. package/dist/core/runtime/stop-guard.d.ts +1 -1
  85. package/dist/core/runtime/stop-guard.js +3 -3
  86. package/dist/core/runtime/tool-group-summary.d.ts +19 -0
  87. package/dist/core/runtime/tool-group-summary.js +117 -0
  88. package/dist/core/termrender/code-doc.d.ts +12 -0
  89. package/dist/core/termrender/code-doc.js +91 -0
  90. package/dist/core/termrender/display.d.ts +12 -0
  91. package/dist/core/termrender/display.js +19 -0
  92. package/dist/core/termrender/termrender.d.ts +73 -0
  93. package/dist/core/termrender/termrender.js +795 -0
  94. package/dist/core/termrender/version.d.ts +1 -0
  95. package/dist/core/termrender/version.js +1 -0
  96. package/dist/core/user-settings.d.ts +14 -0
  97. package/dist/core/user-settings.js +20 -0
  98. package/dist/daemon/crtrd.js +5 -6
  99. package/dist/index.d.ts +2 -0
  100. package/dist/index.js +1 -0
  101. package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
  102. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
  103. package/dist/pi-extensions/canvas-stophook.js +2 -2
  104. package/dist/pi-extensions/canvas-structured-output.js +52 -8
  105. package/dist/pi-extensions/summary-tool.d.ts +33 -0
  106. package/dist/pi-extensions/summary-tool.js +62 -0
  107. package/dist/shared/generated-context.d.ts +19 -4
  108. package/dist/shared/generated-context.js +98 -7
  109. package/dist/shared/tool-groups.d.ts +23 -0
  110. package/dist/shared/tool-groups.js +60 -0
  111. package/dist/types.d.ts +11 -0
  112. package/dist/types.js +1 -0
  113. package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
  114. package/dist/web-client/index.html +1 -1
  115. package/dist/web-client/sw.js +1 -1
  116. package/package.json +3 -1
  117. package/runtime.lock.json +62 -3
  118. package/scripts/postinstall.mjs +14 -1
  119. package/dist/core/command-plugins/store.d.ts +0 -16
  120. package/dist/core/command-plugins/store.js +0 -64
  121. package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
  122. package/dist/core/runtime/fault-recovery-nudge.js +0 -3
@@ -22,11 +22,14 @@
22
22
  // path while builtin tools, diffs, and partial-result streaming keep working.
23
23
  import { Container, Loader, Spacer, Text } from '@earendil-works/pi-tui';
24
24
  import { AssistantMessageComponent, BashExecutionComponent, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomMessageComponent, parseSkillBlock, SkillInvocationMessageComponent, ToolExecutionComponent, UserMessageComponent, } from '@earendil-works/pi-coding-agent';
25
+ import { ICON } from '../../../core/inspector/model.js';
26
+ import { TOOL_ICON } from '../../../core/preview-registry.js';
25
27
  import { attachMarkdownTheme } from '../config.js';
26
28
  import { ContextMessageComponent } from './context-message.js';
27
29
  import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashToolDefinition, } from './crtr-output.js';
28
30
  import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../../core/runtime/session-cycles.js';
29
31
  import { generatedContextPresentation } from '../../../shared/generated-context.js';
32
+ import { assistantVisibleText, isTrueUserMessage, normalizeToolGroupSummary, normalizeToolGroupSummaryRecord, } from '../../../shared/tool-groups.js';
30
33
  import { transformMessageDiagrams } from './diagram.js';
31
34
  import { styleAttachMarkdownHeadings, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-headings.js';
32
35
  import { createCrtrEditToolDefinition } from './edit-diff.js';
@@ -35,6 +38,7 @@ import { ringCompletionBell } from '../chrome/completion-bell.js';
35
38
  import { extractFilePaths } from '../visible-paths.js';
36
39
  import { keepWhenCondensed, liveRegionStart } from './condensed-history.js';
37
40
  import { ViewerAssistantMessageComponent } from './assistant-message.js';
41
+ import { CompositeViewportSource, MeasuredContainer, } from './measured-container.js';
38
42
  import { readConfig, updateRawConfigAtomically } from '../../../core/config.js';
39
43
  import { DEFAULT_CONDENSED_HISTORY, DEFAULT_LIVE_CYCLES } from '../../../types.js';
40
44
  /** Theme-derived chrome colors (spinner / dim / error) are supplied by the
@@ -85,6 +89,17 @@ function readFoldFinishedTools() {
85
89
  return false;
86
90
  }
87
91
  }
92
+ /** Whether this attach should start folded tool groups on their broker-generated
93
+ * summaries. The setting is deliberately attach-local: the broker remains the
94
+ * single summary producer and every viewer independently chooses its display. */
95
+ function readSummarizeToolCalls() {
96
+ try {
97
+ return readConfig('user').summarize_tool_calls;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
88
103
  /** Persist the fold view setting to user config. Best-effort: a viewer must never
89
104
  * die because the config file is unwritable — the in-viewer toggle still applies. */
90
105
  function persistFoldFinishedTools(value) {
@@ -165,6 +180,25 @@ class FoldedToolSeparator extends Spacer {
165
180
  this.setLines(this.folded && this.separatesToolGroup ? 1 : 0);
166
181
  }
167
182
  }
183
+ /** The broker-generated recap of one maximal non-prose run. It remains a
184
+ * direct history child so the render cache's per-child identity stays valid while
185
+ * the group members are hidden. The title is viewer-owned and deterministic;
186
+ * only the bullets and generated counts come from the broker. */
187
+ class GroupSummaryLine extends Text {
188
+ setSummary(summary, toolCount, dim) {
189
+ if (!summary) {
190
+ this.setText('');
191
+ return;
192
+ }
193
+ const count = (value, singular, plural) => `${value} ${value === 1 ? singular : plural}`;
194
+ const stats = [`${TOOL_ICON.bash} ${count(toolCount, 'tool call', 'tool calls')}`];
195
+ if (summary.nodesSpawned >= 1)
196
+ stats.push(`${ICON.graph} ${count(summary.nodesSpawned, 'node spawned', 'nodes spawned')}`);
197
+ if (summary.filesEdited >= 1)
198
+ stats.push(`${TOOL_ICON.edit} ${count(summary.filesEdited, 'file edited', 'files edited')}`);
199
+ this.setText([dim(`✦ ${stats.join(' · ')}`), ...summary.bullets.map((bullet) => dim(` • ${bullet.trim()}`))].join('\n'));
200
+ }
201
+ }
168
202
  class MinimizableToolComponent extends ToolExecutionComponent {
169
203
  minimized = false;
170
204
  expandedState = false;
@@ -207,51 +241,18 @@ class MinimizableToolComponent extends ToolExecutionComponent {
207
241
  }
208
242
  }
209
243
  const defaultErrorStyle = (s) => `\x1b[31m${s}\x1b[39m`;
210
- /** Keep settled conversation history out of picker/editor render hot paths. The
211
- * root TUI still compares the cached lines, but it no longer re-renders every
212
- * markdown/tool component for each repeated navigation key. */
213
- class RenderCacheContainer extends Container {
214
- childCache = new Map();
215
- markDirty(component) {
216
- if (component)
217
- this.childCache.delete(component);
218
- else
219
- this.childCache.clear();
220
- }
221
- removeChild(component) {
222
- super.removeChild(component);
223
- this.childCache.delete(component);
224
- }
225
- clear() {
226
- super.clear();
227
- this.childCache.clear();
228
- }
229
- invalidate() {
230
- super.invalidate();
231
- this.childCache.clear();
232
- }
233
- render(width) {
234
- const lines = [];
235
- for (const child of this.children) {
236
- let cached = this.childCache.get(child);
237
- if (!cached || cached.width !== width) {
238
- cached = { width, lines: child.render(width) };
239
- this.childCache.set(child, cached);
240
- }
241
- lines.push(...cached.lines);
242
- }
243
- return lines;
244
- }
245
- }
246
244
  export class ChatView {
247
245
  tui;
248
246
  /** The chat scroll area handed in by the attach command. */
249
247
  container;
250
248
  /** Settled history is cached independently from the live status area so picker
251
249
  * navigation does not re-render a long conversation on every key repeat. */
252
- historyContainer = new RenderCacheContainer();
250
+ historyContainer = new MeasuredContainer();
253
251
  /** Activity/status area below history. Holds working/compaction/retry loaders. */
254
- statusContainer = new Container();
252
+ statusContainer = new MeasuredContainer();
253
+ /** Optional banner, above history and status in the viewport's flat source. */
254
+ bannerContainer;
255
+ transcript;
255
256
  /** Where `append()` puts a component. Normally `historyContainer`; retargeted to
256
257
  * a nested container while condensed history is being built, so the same
257
258
  * message-rendering path serves both regions. */
@@ -271,6 +272,14 @@ export class ChatView {
271
272
  * call line. Independent of `toolOutputExpanded` — it survives an expand/collapse
272
273
  * and only ever applies to tool calls that are done. */
273
274
  foldSettledTools = readFoldFinishedTools();
275
+ /** Summary mode only changes Ctrl+O when both persistent settings are on. */
276
+ summarizeToolCalls = readSummarizeToolCalls();
277
+ showGroupSummaries = this.summarizeToolCalls && this.foldSettledTools;
278
+ /** Groups exist only while building/rendering the full-fidelity live region. */
279
+ groups = [];
280
+ openGroup;
281
+ groupSummaries = new Map();
282
+ streamingWasGroupMember = false;
274
283
  onFooterEvent;
275
284
  /** Theme-derived colorizers for the activity/status area. */
276
285
  spinnerStyle;
@@ -320,6 +329,12 @@ export class ChatView {
320
329
  transcriptTexts = [];
321
330
  /** Existing file paths found anywhere in the rendered transcript, newest first. */
322
331
  transcriptFilePaths = [];
332
+ /** Canonical copyable transcript blocks, in their rendered visual order. */
333
+ copyBlockRecords = [];
334
+ copyIndex = new Map();
335
+ /** Registrations made while building condensed history are collapsed onto its
336
+ * outer container once that container becomes a top-level viewport block. */
337
+ condensedCopyBlocks = new WeakMap();
323
338
  constructor(tui, container, opts = {}) {
324
339
  this.tui = tui;
325
340
  this.container = container;
@@ -338,15 +353,25 @@ export class ChatView {
338
353
  // Banner sits above history and OUTSIDE it, so resetChat() (which clears only
339
354
  // historyContainer) never wipes it — it persists across every welcome snapshot.
340
355
  if (opts.banner && opts.banner.length > 0) {
341
- const b = new Container();
342
- b.addChild(new Spacer(1));
356
+ const banner = new MeasuredContainer();
357
+ banner.addChild(new Spacer(1));
343
358
  for (const line of opts.banner)
344
- b.addChild(new Text(line, 1, 0));
345
- b.addChild(new Spacer(1));
346
- this.container.addChild(b);
359
+ banner.addChild(new Text(line, 1, 0));
360
+ banner.addChild(new Spacer(1));
361
+ this.bannerContainer = banner;
362
+ this.container.addChild(banner);
347
363
  }
348
364
  this.container.addChild(this.historyContainer);
349
365
  this.container.addChild(this.statusContainer);
366
+ this.transcript = new CompositeViewportSource([
367
+ ...(this.bannerContainer ? [this.bannerContainer] : []),
368
+ this.historyContainer,
369
+ this.statusContainer,
370
+ ]);
371
+ }
372
+ /** The transcript as one flat ordered block list for the viewport. */
373
+ transcriptSource() {
374
+ return this.transcript;
350
375
  }
351
376
  // -------------------------------------------------------------------------
352
377
  // Catch-up: render the welcome snapshot, then live events resume.
@@ -365,6 +390,7 @@ export class ChatView {
365
390
  this.applyingSnapshot = true;
366
391
  this.deferredFrames.length = 0;
367
392
  this.resetChat();
393
+ this.seedToolGroupSummaries(snapshot.toolGroupSummaries);
368
394
  const renderedPendingTools = new Map();
369
395
  const messages = snapshot.messages;
370
396
  const lastIndex = messages.length - 1;
@@ -379,7 +405,7 @@ export class ChatView {
379
405
  if (generation !== this.snapshotGeneration)
380
406
  return;
381
407
  if (condensed)
382
- this.append(condensed);
408
+ this.appendCondensedBlock(condensed);
383
409
  }
384
410
  for (let i = liveStart; i < messages.length; i++) {
385
411
  // Yield to the event loop every SNAPSHOT_YIELD_EVERY messages so queued
@@ -401,7 +427,7 @@ export class ChatView {
401
427
  if (message.role === 'assistant') {
402
428
  // Keep the `/copy` source current from the catch-up transcript too, not
403
429
  // only the live stream (you usually attach AFTER the last reply landed).
404
- const text = this.userMessageText(message);
430
+ const text = assistantVisibleText(message);
405
431
  if (text.trim()) {
406
432
  this.lastAssistantText = text;
407
433
  this.pushTranscriptText(text);
@@ -416,23 +442,37 @@ export class ChatView {
416
442
  snapshot.state.isStreaming &&
417
443
  message.stopReason !== 'aborted' &&
418
444
  message.stopReason !== 'error';
445
+ const hasProse = assistantVisibleText(message).trim() !== '';
446
+ const toolCalls = this.assistantToolCalls(message);
447
+ const persistedGroupKey = toolCalls[0]?.id;
448
+ // A broker restart resets its live grouping state. On reattach, the
449
+ // persisted first-call key is the durable boundary that keeps snapshot
450
+ // reconstruction from joining the new group to pre-restart tools.
451
+ if (hasProse ||
452
+ (persistedGroupKey !== undefined &&
453
+ this.groupSummaries.has(persistedGroupKey) &&
454
+ this.openGroup?.key !== undefined &&
455
+ this.openGroup.key !== persistedGroupKey)) {
456
+ this.closeOpenGroup();
457
+ }
419
458
  let separator;
420
459
  if (isStreamingTail) {
421
460
  this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
422
- this.append(this.streamingComponent);
461
+ this.appendAssistantPart(this.streamingComponent, !hasProse);
462
+ this.registerCopyBlock(this.streamingComponent, 'assistant', '');
463
+ this.streamingWasGroupMember = !hasProse;
423
464
  this.streamingComponent.updateContent(styleAttachMessageMarkdown(message));
424
- separator = this.streamingToolSeparator = this.appendFoldedToolSeparator();
465
+ this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(message));
466
+ separator = this.streamingToolSeparator = this.appendAssistantSeparator(!hasProse);
425
467
  }
426
468
  else {
427
- separator = this.appendAssistantMessage(message);
469
+ separator = this.appendAssistantMessage(message, !hasProse);
428
470
  }
429
- const toolCalls = this.assistantToolCalls(message);
430
471
  if (separator)
431
472
  this.setAssistantSeparatorContent(separator, message);
432
473
  for (const content of toolCalls) {
433
474
  this.pushTranscriptValue(content.arguments);
434
- const component = this.makeToolComponent(content.name, content.id, content.arguments);
435
- this.append(component);
475
+ const component = this.appendToolComponent(content.name, content.id, content.arguments);
436
476
  // (isStreamingTail is never aborted/error, so its tools fall through to
437
477
  // renderedPendingTools → pendingTools, where live tool events find them.)
438
478
  if (message.stopReason === 'aborted' || message.stopReason === 'error') {
@@ -440,6 +480,8 @@ export class ChatView {
440
480
  ? 'Interrupted'
441
481
  : (message.errorMessage ?? 'Error');
442
482
  component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
483
+ this.registerCopyBlock(component, 'tool', errorMessage);
484
+ this.settleGroupTool(component);
443
485
  }
444
486
  else {
445
487
  renderedPendingTools.set(content.id, component);
@@ -451,7 +493,9 @@ export class ChatView {
451
493
  const component = renderedPendingTools.get(message.toolCallId);
452
494
  if (component) {
453
495
  component.updateResult(message);
496
+ this.registerCopyBlock(component, 'tool', this.toolResultText(message));
454
497
  this.historyContainer.markDirty(component);
498
+ this.settleGroupTool(component);
455
499
  renderedPendingTools.delete(message.toolCallId);
456
500
  }
457
501
  }
@@ -464,8 +508,7 @@ export class ChatView {
464
508
  }
465
509
  // A rebuild constructs fresh components, which are born unfolded; re-apply the
466
510
  // setting so a reconnect/welcome does not silently unfold the transcript.
467
- if (this.foldSettledTools)
468
- this.applyToolDisplay();
511
+ this.applyToolDisplay();
469
512
  // If we attached mid-turn, the `agent_start` that spins up the working loader
470
513
  // already fired before we connected — restore it from the snapshot's streaming
471
514
  // state so an actively-working node shows the spinner right away.
@@ -492,7 +535,9 @@ export class ChatView {
492
535
  async buildCondensedBlock(messages, generation) {
493
536
  const condensed = new Container();
494
537
  const previousTarget = this.appendTarget;
538
+ const copyBlocks = [];
495
539
  this.appendTarget = condensed;
540
+ this.condensedCopyBlocks.set(condensed, copyBlocks);
496
541
  try {
497
542
  let built = 0;
498
543
  for (const message of messages) {
@@ -513,8 +558,9 @@ export class ChatView {
513
558
  }
514
559
  finally {
515
560
  this.appendTarget = previousTarget;
561
+ this.condensedCopyBlocks.delete(condensed);
516
562
  }
517
- return condensed.children.length > 0 ? condensed : undefined;
563
+ return condensed.children.length > 0 ? { component: condensed, copyBlocks } : undefined;
518
564
  }
519
565
  /** Defer a live broker frame when a snapshot build is in flight. Returns true
520
566
  * (and enqueues the replay closure) while building so the caller returns
@@ -535,12 +581,16 @@ export class ChatView {
535
581
  }
536
582
  /** Clear live turn state after the broker reports completion or the viewer observes dormancy. */
537
583
  settleRun() {
584
+ for (const component of this.pendingTools.values())
585
+ this.settleGroupTool(component);
538
586
  if (this.streamingComponent) {
539
587
  this.historyContainer.removeChild(this.streamingComponent);
588
+ this.unregisterCopyBlock(this.streamingComponent);
540
589
  this.streamingComponent = undefined;
541
590
  }
542
591
  this.streamingToolSeparator = undefined;
543
592
  this.pendingTools.clear();
593
+ this.applyToolDisplay();
544
594
  this.setActivity(undefined);
545
595
  this.settleBashComponent();
546
596
  }
@@ -555,36 +605,48 @@ export class ChatView {
555
605
  // Pure client-side render state — no broker round-trip — ported from pi's
556
606
  // `setToolsExpanded` / `toggleThinkingBlockVisibility`.
557
607
  // -------------------------------------------------------------------------
558
- /** Ctrl+O / app.tools.expand — flip global tool-output expansion and apply it to
559
- * every rendered component that supports it. Returns the new expanded state so
560
- * the caller can surface a notice. Condensed cycles hold no tool components, so
561
- * they are unaffected by design. */
608
+ /** Ctrl+O prefers summaries when the user enabled both folding and summary
609
+ * generation. Otherwise it remains pi's global output expansion toggle. */
562
610
  toggleToolsExpanded() {
611
+ if (this.summarizeToolCalls && this.foldSettledTools) {
612
+ this.showGroupSummaries = !this.showGroupSummaries;
613
+ this.toolOutputExpanded = !this.showGroupSummaries;
614
+ this.applyToolDisplay();
615
+ return { kind: 'summaries', shown: this.showGroupSummaries };
616
+ }
563
617
  this.toolOutputExpanded = !this.toolOutputExpanded;
564
618
  this.applyToolDisplay();
565
- return this.toolOutputExpanded;
619
+ return { kind: 'output', expanded: this.toolOutputExpanded };
566
620
  }
567
621
  /** The `fold finished tool calls` view setting (`/fold-tools`, Alt+C → w → f).
568
622
  * Returns the new state so the caller can surface a notice. */
569
623
  toggleFoldSettledTools() {
570
624
  this.foldSettledTools = !this.foldSettledTools;
571
625
  persistFoldFinishedTools(this.foldSettledTools);
626
+ // Turning folding back on restores the configured default (summaries),
627
+ // rather than preserving a stale Ctrl+O choice from an unfolded view.
628
+ if (this.summarizeToolCalls && this.foldSettledTools)
629
+ this.showGroupSummaries = true;
572
630
  this.applyToolDisplay();
573
631
  return this.foldSettledTools;
574
632
  }
575
633
  /** Push both display settings onto every rendered component. A tool call still in
576
- * `pendingTools` is in flight, so it is never folded — you keep watching live
577
- * output, and it folds on its own once `tool_execution_end` settles it. */
634
+ * flight is never hidden or minimized. Only a settled group with a broker-known
635
+ * summary collapses; incomplete/failed generation leaves its folded call lines. */
578
636
  applyToolDisplay() {
579
637
  const inFlight = new Set(this.pendingTools.values());
580
- this.updateFoldedToolSeparators(inFlight);
638
+ for (const group of this.groups)
639
+ for (const tool of group.inFlight)
640
+ inFlight.add(tool);
581
641
  for (const child of this.historyContainer.children) {
582
- if (child instanceof MinimizableToolComponent) {
642
+ if (child instanceof MinimizableToolComponent)
583
643
  child.setMinimized(this.foldSettledTools && !inFlight.has(child));
584
- }
585
644
  if (isExpandable(child))
586
645
  child.setExpanded(this.toolOutputExpanded);
587
646
  }
647
+ for (const group of this.groups)
648
+ this.applyGroupDisplay(group);
649
+ this.updateFoldedToolSeparators(inFlight);
588
650
  this.historyContainer.markDirty();
589
651
  this.tui.requestRender();
590
652
  }
@@ -615,6 +677,8 @@ export class ChatView {
615
677
  return;
616
678
  switch (event.type) {
617
679
  case 'agent_start': {
680
+ for (const component of this.pendingTools.values())
681
+ this.settleGroupTool(component);
618
682
  this.pendingTools.clear();
619
683
  this.setActivity(this.makeLoader('Working...'));
620
684
  break;
@@ -644,16 +708,23 @@ export class ChatView {
644
708
  this.addMessageToChat(event.message);
645
709
  }
646
710
  else if (event.message.role === 'assistant') {
711
+ const hasProse = assistantVisibleText(event.message).trim() !== '';
712
+ if (hasProse)
713
+ this.closeOpenGroup();
647
714
  this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
648
- this.append(this.streamingComponent);
715
+ this.streamingWasGroupMember = !hasProse;
716
+ this.appendAssistantPart(this.streamingComponent, !hasProse);
717
+ this.registerCopyBlock(this.streamingComponent, 'assistant', '');
649
718
  this.streamingComponent.updateContent(styleAttachMessageMarkdown(event.message));
650
- this.streamingToolSeparator = this.appendFoldedToolSeparator();
719
+ this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
720
+ this.streamingToolSeparator = this.appendAssistantSeparator(!hasProse);
651
721
  this.setAssistantSeparatorContent(this.streamingToolSeparator, event.message);
652
722
  }
653
723
  break;
654
724
  }
655
725
  case 'message_update': {
656
726
  if (this.streamingComponent && event.message.role === 'assistant') {
727
+ this.promoteStreamingAssistantToBoundary(event.message);
657
728
  // Render any ALREADY-COMPLETE diagram fence to a diagram mid-stream
658
729
  // (transformMessageDiagrams only matches closed fences and is memoized,
659
730
  // so an unclosed fence still streaming stays raw and settles later).
@@ -661,6 +732,7 @@ export class ChatView {
661
732
  // of waiting for message_end — noticeable when more text follows it.
662
733
  const updated = styleAttachMessageMarkdown(transformMessageDiagrams(event.message, this.renderWidth()));
663
734
  this.streamingComponent.updateContent(updated);
735
+ this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
664
736
  this.historyContainer.markDirty(this.streamingComponent);
665
737
  const toolCalls = this.assistantToolCalls(event.message);
666
738
  if (this.streamingToolSeparator)
@@ -673,8 +745,7 @@ export class ChatView {
673
745
  this.historyContainer.markDirty(existing);
674
746
  }
675
747
  else {
676
- const component = this.makeToolComponent(content.name, content.id, content.arguments);
677
- this.append(component);
748
+ const component = this.appendToolComponent(content.name, content.id, content.arguments);
678
749
  this.pendingTools.set(content.id, component);
679
750
  }
680
751
  }
@@ -686,8 +757,11 @@ export class ChatView {
686
757
  break;
687
758
  // Stash the completed reply's text for `/copy` (skip aborted/error turns,
688
759
  // whose text is partial or absent).
760
+ this.promoteStreamingAssistantToBoundary(event.message);
761
+ if (this.streamingComponent)
762
+ this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
689
763
  if (event.message.stopReason !== 'aborted' && event.message.stopReason !== 'error') {
690
- const text = this.userMessageText(event.message);
764
+ const text = assistantVisibleText(event.message);
691
765
  if (text.trim()) {
692
766
  this.lastAssistantText = text;
693
767
  this.pushTranscriptText(text);
@@ -715,6 +789,8 @@ export class ChatView {
715
789
  const text = errorMessage ?? 'Error';
716
790
  for (const component of this.pendingTools.values()) {
717
791
  component.updateResult({ content: [{ type: 'text', text }], isError: true });
792
+ this.registerCopyBlock(component, 'tool', text);
793
+ this.settleGroupTool(component);
718
794
  this.historyContainer.markDirty(component);
719
795
  }
720
796
  this.pendingTools.clear();
@@ -736,8 +812,7 @@ export class ChatView {
736
812
  this.pushTranscriptValue(event.args);
737
813
  let component = this.pendingTools.get(event.toolCallId);
738
814
  if (!component) {
739
- component = this.makeToolComponent(event.toolName, event.toolCallId, event.args);
740
- this.append(component);
815
+ component = this.appendToolComponent(event.toolName, event.toolCallId, event.args);
741
816
  this.pendingTools.set(event.toolCallId, component);
742
817
  }
743
818
  component.markExecutionStarted();
@@ -749,6 +824,7 @@ export class ChatView {
749
824
  const component = this.pendingTools.get(event.toolCallId);
750
825
  if (component) {
751
826
  component.updateResult({ ...event.partialResult, isError: false }, true);
827
+ this.registerCopyBlock(component, 'tool', this.toolResultText(event.partialResult));
752
828
  this.historyContainer.markDirty(component);
753
829
  }
754
830
  break;
@@ -758,11 +834,10 @@ export class ChatView {
758
834
  const component = this.pendingTools.get(event.toolCallId);
759
835
  if (component) {
760
836
  component.updateResult({ ...event.result, isError: event.isError });
837
+ this.registerCopyBlock(component, 'tool', this.toolResultText(event.result));
761
838
  this.pendingTools.delete(event.toolCallId);
762
- // Settled → it now obeys the fold setting it was exempt from while live.
763
- if (this.foldSettledTools)
764
- component.setMinimized(true);
765
- this.updateFoldedToolSeparators();
839
+ this.settleGroupTool(component);
840
+ this.applyToolDisplay();
766
841
  this.historyContainer.markDirty(component);
767
842
  }
768
843
  break;
@@ -822,7 +897,7 @@ export class ChatView {
822
897
  break;
823
898
  }
824
899
  // No unconditional markDirty here: appends/removes invalidate through
825
- // RenderCacheContainer, and branches that mutate a child in place explicitly
900
+ // MeasuredContainer, and branches that mutate a child in place explicitly
826
901
  // mark it dirty. Activity-only events leave settled history cached while the
827
902
  // independent status loader animates.
828
903
  this.tui.requestRender();
@@ -836,6 +911,7 @@ export class ChatView {
836
911
  bashStart(command, excludeFromContext) {
837
912
  if (this.deferIfBuilding(() => this.bashStart(command, excludeFromContext)))
838
913
  return;
914
+ this.closeOpenGroup();
839
915
  this.settleBashComponent();
840
916
  let component;
841
917
  component = new BashExecutionComponent(command, this.componentTui(() => component), excludeFromContext);
@@ -872,11 +948,18 @@ export class ChatView {
872
948
  if (generated !== null &&
873
949
  (message.role !== 'custom' || message.display === true)) {
874
950
  this.pushTranscriptText(generated.body);
875
- this.append(new ContextMessageComponent(generated.body, this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`));
951
+ const component = new ContextMessageComponent(generated.body, this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`);
952
+ // Bearings and revive kickoffs only belong to an already-open run; they
953
+ // never create a summary block by themselves. Other injected context may.
954
+ if (generated.kind === 'bearings' || generated.kind === 'revive')
955
+ this.appendExistingGroupMember(component);
956
+ else
957
+ this.appendGroupMember(component);
876
958
  return;
877
959
  }
878
960
  switch (message.role) {
879
961
  case 'bashExecution': {
962
+ this.closeOpenGroup();
880
963
  this.pushTranscriptText(message.output ?? '');
881
964
  const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext);
882
965
  if (message.output)
@@ -894,7 +977,7 @@ export class ChatView {
894
977
  if (message.display) {
895
978
  this.pushTranscriptText(this.userMessageText(message));
896
979
  if (message.customType === CRTR_OUTPUT_CUSTOM_TYPE) {
897
- this.append(new CrtrOutputMessageComponent(this.userMessageText(message), this.toolOutputExpanded, this.crtrOutputTheme()));
980
+ this.appendGroupMember(new CrtrOutputMessageComponent(this.userMessageText(message), this.toolOutputExpanded, this.crtrOutputTheme()));
898
981
  break;
899
982
  }
900
983
  // A refresh-yield cycle boundary (session-cycles.ts, synthetic — never
@@ -902,6 +985,7 @@ export class ChatView {
902
985
  // message card, so scrolling through a multi-cycle node's full history
903
986
  // reads as one conversation with clear seams between cycles.
904
987
  if (message.customType === CRTR_CYCLE_DIVIDER_CUSTOM_TYPE) {
988
+ this.closeOpenGroup();
905
989
  this.append(new Spacer(1));
906
990
  this.append(new Text(this.dimStyle(`── ${this.userMessageText(message)} ──`), 1, 0));
907
991
  this.append(new Spacer(1));
@@ -909,11 +993,12 @@ export class ChatView {
909
993
  }
910
994
  const component = new CustomMessageComponent(styleAttachMessageMarkdown(message), undefined, attachMarkdownTheme());
911
995
  component.setExpanded(this.toolOutputExpanded);
912
- this.append(component);
996
+ this.appendGroupMember(component);
913
997
  }
914
998
  break;
915
999
  }
916
1000
  case 'compactionSummary': {
1001
+ this.closeOpenGroup();
917
1002
  this.append(new Spacer(1));
918
1003
  const component = new CompactionSummaryMessageComponent(styleAttachSummaryMarkdown(message), attachMarkdownTheme());
919
1004
  component.setExpanded(this.toolOutputExpanded);
@@ -921,35 +1006,47 @@ export class ChatView {
921
1006
  break;
922
1007
  }
923
1008
  case 'branchSummary': {
1009
+ this.closeOpenGroup();
924
1010
  this.append(new Spacer(1));
925
1011
  const component = new BranchSummaryMessageComponent(styleAttachSummaryMarkdown(message), attachMarkdownTheme());
926
1012
  component.setExpanded(this.toolOutputExpanded);
927
1013
  this.append(component);
1014
+ this.registerCopyBlock(component, 'assistant', message.summary);
928
1015
  break;
929
1016
  }
930
1017
  case 'user': {
1018
+ if (isTrueUserMessage(message))
1019
+ this.closeOpenGroup();
931
1020
  const textContent = this.userMessageText(message);
932
1021
  if (!textContent)
933
1022
  break;
934
1023
  this.pushTranscriptText(textContent);
935
1024
  if (this.chatChildCount() > 0)
936
- this.append(new Spacer(1));
1025
+ this.appendUserPart(new Spacer(1), !isTrueUserMessage(message));
937
1026
  const skillBlock = parseSkillBlock(textContent);
938
1027
  if (skillBlock) {
939
1028
  const component = new SkillInvocationMessageComponent({ ...skillBlock, content: styleAttachMarkdownHeadings(skillBlock.content) }, attachMarkdownTheme());
940
1029
  component.setExpanded(this.toolOutputExpanded);
941
- this.append(component);
1030
+ this.appendUserPart(component, !isTrueUserMessage(message));
1031
+ this.registerCopyBlock(component, 'user', textContent);
942
1032
  if (skillBlock.userMessage) {
943
- this.append(new UserMessageComponent(styleAttachMarkdownHeadings(skillBlock.userMessage), attachMarkdownTheme()));
1033
+ const userMessage = new UserMessageComponent(styleAttachMarkdownHeadings(skillBlock.userMessage), attachMarkdownTheme());
1034
+ this.appendUserPart(userMessage, !isTrueUserMessage(message));
1035
+ this.registerCopyBlock(userMessage, 'user', skillBlock.userMessage);
944
1036
  }
945
1037
  }
946
1038
  else {
947
- this.append(new UserMessageComponent(styleAttachMarkdownHeadings(textContent), attachMarkdownTheme()));
1039
+ const component = new UserMessageComponent(styleAttachMarkdownHeadings(textContent), attachMarkdownTheme());
1040
+ this.appendUserPart(component, !isTrueUserMessage(message));
1041
+ this.registerCopyBlock(component, 'user', textContent);
948
1042
  }
949
1043
  break;
950
1044
  }
951
1045
  case 'assistant': {
952
- this.appendAssistantMessage(message);
1046
+ const hasProse = assistantVisibleText(message).trim() !== '';
1047
+ if (hasProse)
1048
+ this.closeOpenGroup();
1049
+ this.appendAssistantMessage(message, !hasProse);
953
1050
  break;
954
1051
  }
955
1052
  // toolResult is rendered inline with its tool call (handled in applySnapshot).
@@ -961,19 +1058,152 @@ export class ChatView {
961
1058
  // Helpers
962
1059
  // -------------------------------------------------------------------------
963
1060
  /** Render an assistant reply plus its initially-hidden boundary separator. */
964
- appendAssistantMessage(message) {
965
- this.append(new ViewerAssistantMessageComponent(styleAttachMessageMarkdown(transformMessageDiagrams(message, this.renderWidth())), this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel));
966
- const separator = this.appendFoldedToolSeparator();
1061
+ appendAssistantMessage(message, groupMember = false) {
1062
+ const component = new ViewerAssistantMessageComponent(styleAttachMessageMarkdown(transformMessageDiagrams(message, this.renderWidth())), this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
1063
+ this.appendAssistantPart(component, groupMember);
1064
+ this.registerCopyBlock(component, 'assistant', assistantVisibleText(message));
1065
+ const separator = this.appendAssistantSeparator(groupMember);
967
1066
  this.setAssistantSeparatorContent(separator, message);
968
1067
  return separator;
969
1068
  }
970
1069
  /** Add a separator in transcript order now; it renders only after a following
971
1070
  * tool settles in the folded view. */
972
- appendFoldedToolSeparator() {
1071
+ appendAssistantSeparator(groupMember = false) {
973
1072
  const separator = new FoldedToolSeparator();
974
- this.append(separator);
1073
+ if (groupMember)
1074
+ this.appendGroupMember(separator);
1075
+ else
1076
+ this.append(separator);
975
1077
  return separator;
976
1078
  }
1079
+ appendAssistantPart(component, groupMember) {
1080
+ if (groupMember)
1081
+ this.appendGroupMember(component);
1082
+ else
1083
+ this.append(component);
1084
+ }
1085
+ appendUserPart(component, groupMember) {
1086
+ if (groupMember)
1087
+ this.appendGroupMember(component);
1088
+ else
1089
+ this.append(component);
1090
+ }
1091
+ /** Only the full-fidelity trailing region gets summary groups. Condensed
1092
+ * history intentionally drops tool traffic, so grouping there would create
1093
+ * phantom summary rows and cross the live/condensed boundary. */
1094
+ isLiveGroupRegion() {
1095
+ return this.appendTarget === this.historyContainer;
1096
+ }
1097
+ ensureGroup() {
1098
+ if (!this.isLiveGroupRegion())
1099
+ return undefined;
1100
+ if (this.openGroup)
1101
+ return this.openGroup;
1102
+ const summaryLine = new GroupSummaryLine('', 0, 0);
1103
+ const group = { members: [], summaryLine, toolCount: 0, inFlight: new Set() };
1104
+ this.append(summaryLine);
1105
+ // `Text('')` still occupies a row in pi-tui. It becomes visible only when
1106
+ // a settled, known summary is actually selected for this group.
1107
+ this.historyContainer.setHidden(summaryLine, true);
1108
+ this.groups.push(group);
1109
+ this.openGroup = group;
1110
+ return group;
1111
+ }
1112
+ closeOpenGroup() {
1113
+ this.openGroup = undefined;
1114
+ }
1115
+ appendGroupMember(component) {
1116
+ const group = this.ensureGroup();
1117
+ this.append(component);
1118
+ if (group)
1119
+ group.members.push(component);
1120
+ }
1121
+ /** Used for context that may join an existing run but must not create one. */
1122
+ appendExistingGroupMember(component) {
1123
+ this.append(component);
1124
+ if (this.isLiveGroupRegion() && this.openGroup)
1125
+ this.openGroup.members.push(component);
1126
+ }
1127
+ removeGroupMember(component) {
1128
+ for (const group of this.groups) {
1129
+ const index = group.members.indexOf(component);
1130
+ if (index !== -1)
1131
+ group.members.splice(index, 1);
1132
+ }
1133
+ this.historyContainer.setHidden(component, false);
1134
+ }
1135
+ /** Add one tool as a group member, moving the assistant's boundary separator
1136
+ * behind the new summary row without changing the separator's identity. */
1137
+ appendToolComponent(name, id, args) {
1138
+ const trailingSeparator = this.isLiveGroupRegion() &&
1139
+ !this.openGroup &&
1140
+ this.historyContainer.children.at(-1) instanceof FoldedToolSeparator
1141
+ ? this.historyContainer.children.at(-1)
1142
+ : undefined;
1143
+ if (trailingSeparator)
1144
+ this.historyContainer.removeChild(trailingSeparator);
1145
+ const group = this.ensureGroup();
1146
+ if (trailingSeparator) {
1147
+ this.append(trailingSeparator);
1148
+ group?.members.push(trailingSeparator);
1149
+ }
1150
+ const component = this.makeToolComponent(name, id, args);
1151
+ this.appendGroupMember(component);
1152
+ this.registerCopyBlock(component, 'tool', '');
1153
+ if (group) {
1154
+ group.key ??= id;
1155
+ group.toolCount++;
1156
+ group.inFlight.add(component);
1157
+ }
1158
+ return component;
1159
+ }
1160
+ settleGroupTool(component) {
1161
+ for (const group of this.groups)
1162
+ group.inFlight.delete(component);
1163
+ }
1164
+ /** An assistant starts as a non-boundary while its text is empty. The first
1165
+ * visible prose turns it into the boundary without recreating either pi-owned
1166
+ * component (their direct-child cache identities remain intact). */
1167
+ promoteStreamingAssistantToBoundary(message) {
1168
+ if (!this.streamingWasGroupMember || assistantVisibleText(message).trim() === '')
1169
+ return;
1170
+ if (this.streamingComponent)
1171
+ this.removeGroupMember(this.streamingComponent);
1172
+ if (this.streamingToolSeparator)
1173
+ this.removeGroupMember(this.streamingToolSeparator);
1174
+ this.streamingWasGroupMember = false;
1175
+ this.closeOpenGroup();
1176
+ }
1177
+ seedToolGroupSummaries(summaries) {
1178
+ this.groupSummaries.clear();
1179
+ for (const [key, summary] of Object.entries(normalizeToolGroupSummaryRecord(summaries))) {
1180
+ this.groupSummaries.set(key, summary);
1181
+ }
1182
+ }
1183
+ /** Broker frames can arrive before or after their group's events, and snapshot
1184
+ * rebuilding defers them through the single-writer gate, so this stores first
1185
+ * and applies to every currently-rendered matching group. */
1186
+ applyToolGroupSummary(key, summary) {
1187
+ if (this.deferIfBuilding(() => this.applyToolGroupSummary(key, summary)))
1188
+ return;
1189
+ const normalized = normalizeToolGroupSummary(summary);
1190
+ if (key.trim() === '' || normalized === null)
1191
+ return;
1192
+ this.groupSummaries.set(key, normalized);
1193
+ this.applyToolDisplay();
1194
+ }
1195
+ applyGroupDisplay(group) {
1196
+ const summary = group.key === undefined ? undefined : this.groupSummaries.get(group.key);
1197
+ const collapsed = this.foldSettledTools &&
1198
+ this.showGroupSummaries &&
1199
+ group.toolCount > 0 &&
1200
+ group.inFlight.size === 0 &&
1201
+ summary !== undefined;
1202
+ group.summaryLine.setSummary(collapsed ? summary : undefined, group.toolCount, this.dimStyle);
1203
+ this.historyContainer.setHidden(group.summaryLine, !collapsed);
1204
+ for (const member of group.members)
1205
+ this.historyContainer.setHidden(member, collapsed);
1206
+ }
977
1207
  /** Make each separator visible only if its immediately-following tool call is
978
1208
  * actually folded; in-flight calls remain flush with their live output. */
979
1209
  updateFoldedToolSeparators(inFlight = new Set(this.pendingTools.values())) {
@@ -984,7 +1214,9 @@ export class ChatView {
984
1214
  continue;
985
1215
  const followingTool = children[i + 1];
986
1216
  separator.setFolded(this.foldSettledTools &&
1217
+ !this.historyContainer.hiddenChildren.has(separator) &&
987
1218
  followingTool instanceof MinimizableToolComponent &&
1219
+ !this.historyContainer.hiddenChildren.has(followingTool) &&
988
1220
  !inFlight.has(followingTool));
989
1221
  this.historyContainer.markDirty(separator);
990
1222
  }
@@ -1054,15 +1286,33 @@ export class ChatView {
1054
1286
  this.settleBashComponent();
1055
1287
  this.streamingComponent = undefined;
1056
1288
  this.streamingToolSeparator = undefined;
1289
+ this.streamingWasGroupMember = false;
1057
1290
  this.foldedToolSeparatorContents.clear();
1291
+ this.groups.length = 0;
1292
+ this.openGroup = undefined;
1293
+ this.groupSummaries.clear();
1058
1294
  this.pendingTools.clear();
1059
1295
  this.lastAssistantText = undefined;
1296
+ this.copyBlockRecords.length = 0;
1297
+ this.copyIndex.clear();
1060
1298
  this.transcriptTexts.length = 0;
1061
1299
  this.transcriptFilePaths.length = 0;
1062
1300
  this.historyContainer.clear();
1063
1301
  }
1064
1302
  makeLoader(message) {
1065
- return new Loader(this.tui, this.spinnerStyle, this.dimStyle, message);
1303
+ // Loader updates itself on an interval. Its TUI callback has to evict the
1304
+ // status block's cached frame before requesting the next render.
1305
+ let loader;
1306
+ const statusTui = {
1307
+ requestRender: (force) => {
1308
+ if (loader)
1309
+ this.statusContainer.markDirty(loader);
1310
+ this.tui.requestRender(force);
1311
+ },
1312
+ terminal: this.tui.terminal,
1313
+ };
1314
+ loader = new Loader(statusTui, this.spinnerStyle, this.dimStyle, message);
1315
+ return loader;
1066
1316
  }
1067
1317
  /** Let a self-updating history child invalidate only its own cached lines. */
1068
1318
  componentTui(component) {
@@ -1092,10 +1342,12 @@ export class ChatView {
1092
1342
  this.statusContainer.addChild(loader);
1093
1343
  }
1094
1344
  showStatus(message) {
1345
+ this.closeOpenGroup();
1095
1346
  this.append(new Spacer(1));
1096
1347
  this.append(new Text(this.dimStyle(message), 1, 0));
1097
1348
  }
1098
1349
  showError(message) {
1350
+ this.closeOpenGroup();
1099
1351
  this.append(new Spacer(1));
1100
1352
  this.append(new Text(this.errorStyle(message), 1, 0));
1101
1353
  }
@@ -1131,6 +1383,98 @@ export class ChatView {
1131
1383
  const t = this.lastAssistantText?.trim();
1132
1384
  return t ? t : undefined;
1133
1385
  }
1386
+ /** Copyable source text for a transcript block, if it has any. */
1387
+ blockText(block) {
1388
+ const index = this.copyIndex.get(block);
1389
+ return index === undefined ? undefined : this.copyBlockRecords[index]?.text;
1390
+ }
1391
+ /** The canonical ordered copy record, oldest → newest. */
1392
+ copyBlocks() {
1393
+ return this.copyBlockRecords;
1394
+ }
1395
+ /** Newest tool call's result text (`/copy-tool`). */
1396
+ getLastToolOutputText() {
1397
+ for (let i = this.copyBlockRecords.length - 1; i >= 0; i--) {
1398
+ const copyBlock = this.copyBlockRecords[i];
1399
+ if (copyBlock.kind !== 'tool')
1400
+ continue;
1401
+ return copyBlock.text.trim() === '' ? undefined : copyBlock.text;
1402
+ }
1403
+ return undefined;
1404
+ }
1405
+ /** Every copyable block's text, oldest → newest, joined by blank lines. */
1406
+ getTranscriptPlainText() {
1407
+ return this.copyBlockRecords
1408
+ .map((copyBlock) => copyBlock.text)
1409
+ .filter((text) => text.trim() !== '')
1410
+ .join('\n\n');
1411
+ }
1412
+ registerCopyBlock(block, kind, text) {
1413
+ const condensedCopyBlocks = this.condensedCopyBlocks.get(this.appendTarget);
1414
+ if (condensedCopyBlocks) {
1415
+ const index = condensedCopyBlocks.findIndex((copyBlock) => copyBlock.block === block);
1416
+ if (index === -1)
1417
+ condensedCopyBlocks.push({ block, kind, text });
1418
+ else
1419
+ condensedCopyBlocks[index].text = text;
1420
+ return;
1421
+ }
1422
+ const index = this.copyIndex.get(block);
1423
+ if (index === undefined) {
1424
+ this.copyIndex.set(block, this.copyBlockRecords.length);
1425
+ this.copyBlockRecords.push({ block, kind, text });
1426
+ }
1427
+ else {
1428
+ this.copyBlockRecords[index].text = text;
1429
+ }
1430
+ }
1431
+ unregisterCopyBlock(block) {
1432
+ const condensedCopyBlocks = this.condensedCopyBlocks.get(this.appendTarget);
1433
+ if (condensedCopyBlocks) {
1434
+ const index = condensedCopyBlocks.findIndex((copyBlock) => copyBlock.block === block);
1435
+ if (index !== -1)
1436
+ condensedCopyBlocks.splice(index, 1);
1437
+ return;
1438
+ }
1439
+ const index = this.copyIndex.get(block);
1440
+ if (index === undefined)
1441
+ return;
1442
+ this.copyBlockRecords.splice(index, 1);
1443
+ this.copyIndex.delete(block);
1444
+ for (let i = index; i < this.copyBlockRecords.length; i++) {
1445
+ this.copyIndex.set(this.copyBlockRecords[i].block, i);
1446
+ }
1447
+ }
1448
+ appendCondensedBlock({ component, copyBlocks }) {
1449
+ this.append(component);
1450
+ this.registerCopyBlock(component, 'condensed', copyBlocks.map((copyBlock) => copyBlock.text).filter((text) => text.trim() !== '').join('\n\n'));
1451
+ }
1452
+ /** Flatten arbitrary tool result payloads into the visible text copy target. */
1453
+ toolResultText(result) {
1454
+ if (typeof result === 'string')
1455
+ return result;
1456
+ if (typeof result === 'object' && result !== null && 'content' in result) {
1457
+ const content = result.content;
1458
+ if (typeof content === 'string')
1459
+ return content;
1460
+ if (Array.isArray(content)) {
1461
+ return content
1462
+ .filter((part) => typeof part === 'object' &&
1463
+ part !== null &&
1464
+ part.type === 'text' &&
1465
+ typeof part.text === 'string')
1466
+ .map((part) => part.text)
1467
+ .join('');
1468
+ }
1469
+ }
1470
+ try {
1471
+ const text = JSON.stringify(result);
1472
+ return typeof text === 'string' ? text : '';
1473
+ }
1474
+ catch {
1475
+ return '';
1476
+ }
1477
+ }
1134
1478
  pushTranscriptText(text) {
1135
1479
  const trimmed = text.trim();
1136
1480
  if (trimmed === '')