@pellux/goodvibes-tui 1.19.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. package/src/version.ts +1 -1
@@ -25,6 +25,10 @@
25
25
  * - the absolute message index (embedded in every collapseKey and used for the
26
26
  * system-message kind lookup)
27
27
  * - the system-message kind (drives the error-navigation registry side effect)
28
+ * - this message's tool-group membership (see conversation-tool-groups.ts):
29
+ * whether it's folded under a group header, which member owns that header,
30
+ * and the group's honest tool/line counts — a group growing as new tool
31
+ * results stream in changes these for every existing member
28
32
  * - the live values of every collapseState key the render READS (recorded via a
29
33
  * proxy during a miss; a collapse toggle flips a recorded value and
30
34
  * invalidates exactly the owning message)
@@ -50,7 +54,9 @@ import {
50
54
  renderConversationToolMessage,
51
55
  renderConversationUserMessage,
52
56
  collectCompletedToolCallIds,
57
+ isFoldedGroupMember,
53
58
  } from './conversation-rendering.ts';
59
+ import { computeToolGroupMembership, type ToolGroupMembership } from './conversation-tool-groups.ts';
54
60
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
55
61
  // SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
56
62
  // conversation-rendering.ts's own import.
@@ -96,17 +102,42 @@ interface KeyMeta {
96
102
  readonly blockBase: number;
97
103
  readonly kind: SystemMessageKind | undefined;
98
104
  /**
99
- * True when this (assistant) message has a tool call with no result yet — it
100
- * renders as pending rather than done. Keyed so the entry invalidates and
101
- * re-renders ✓ once the tool result arrives. (item 2c.)
105
+ * Per-call completion signature for an assistant message's tool calls (see
106
+ * pendingToolKeyOf) — undefined when the message has none. Each call's id
107
+ * is paired with whether it has a matching result yet, in call order, so a
108
+ * SINGLE call finishing invalidates and re-renders that call's glyph from
109
+ * ◌ to ✓ immediately. A prior aggregate boolean only flipped once EVERY
110
+ * call in the message had completed, so in a multi-call turn the earlier
111
+ * calls kept showing ◌ until the last result arrived.
102
112
  */
103
- readonly hasPendingTool: boolean;
113
+ readonly pendingToolKey: string | undefined;
114
+ /**
115
+ * Tool-group membership fields (see conversation-tool-groups.ts), flattened
116
+ * so a change invalidates the cache like any other structural input — e.g.
117
+ * a group growing from 2 to 3 members as tool results stream in changes
118
+ * groupToolCount/groupTotalLines for every existing member, and must
119
+ * re-render all of them with the updated header counts. `groupKey` is
120
+ * undefined when the message isn't part of a folded group.
121
+ */
122
+ readonly groupKey: string | undefined;
123
+ readonly groupIsFirst: boolean;
124
+ readonly groupToolCount: number;
125
+ readonly groupTotalLines: number;
104
126
  }
105
127
 
106
- /** Whether an assistant message has any tool call still awaiting a result. */
107
- function hasPendingToolCall(m: Message, completed: ReadonlySet<string>): boolean {
108
- if (m.role !== 'assistant' || !m.toolCalls) return false;
109
- return m.toolCalls.some((tc) => tc.id === undefined || !completed.has(tc.id));
128
+ /**
129
+ * Per-call completion signature for an assistant message's tool calls, in
130
+ * call order — `id:0` or `id:1` per call, joined. Undefined for a message
131
+ * with no tool calls at all (nothing to key). Comparing this instead of an
132
+ * aggregate boolean lets exactly the calls whose completion status changed
133
+ * invalidate the entry, rather than waiting for every call in the message to
134
+ * complete before any of them re-renders as done.
135
+ */
136
+ function pendingToolKeyOf(m: Message, completed: ReadonlySet<string>): string | undefined {
137
+ if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) return undefined;
138
+ return m.toolCalls
139
+ .map((tc) => `${tc.id ?? ''}:${tc.id !== undefined && completed.has(tc.id) ? 1 : 0}`)
140
+ .join('|');
110
141
  }
111
142
 
112
143
  interface CacheEntry {
@@ -288,31 +319,51 @@ export class MessageLineCache {
288
319
  // Tool calls with no matching tool-result message are still pending; the
289
320
  // render context and the cache key both depend on this. (item 2c.)
290
321
  const completedToolCallIds = collectCompletedToolCallIds(messages);
291
- const renderContext: ConversationRenderContext = { ...context, completedToolCallIds };
322
+ // Fold runs of >=2 consecutive tool-result messages sharing one assistant
323
+ // turn under a single collapsible header (see conversation-tool-groups.ts).
324
+ // Computed once per pass, over the same slice+offset appendConversationMessages
325
+ // would see, so both entry points fold identically.
326
+ const toolGroupMembership = computeToolGroupMembership(messages, msgIndexOffset, width);
327
+ const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, toolGroupMembership };
292
328
 
293
329
  const touched = new Set<number>();
330
+ // Header line of each currently-open tool-group, keyed by groupKey — the
331
+ // first member's own registered line, recorded as that member is reached
332
+ // below so later (non-first) members of the SAME group can resolve to it.
333
+ const groupHeaderLines = new Map<string, number>();
294
334
 
295
335
  for (let i = 0; i < messages.length; i++) {
296
336
  const message = messages[i]!;
297
337
  const absoluteIdx = msgIndexOffset + i;
298
338
  const base = context.history.getLineCount();
299
339
  const blockBase = context.blockRegistry.length;
300
- messageLineRegistry[absoluteIdx] = base;
301
340
 
302
341
  const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
303
342
  const kind = context.messageKindRegistry.get(absoluteIdx);
304
- const hasPendingTool = hasPendingToolCall(message, completedToolCallIds);
343
+ const pendingToolKey = pendingToolKeyOf(message, completedToolCallIds);
344
+ const groupMembership = toolGroupMembership.get(absoluteIdx);
345
+
346
+ if (groupMembership?.isFirst) groupHeaderLines.set(groupMembership.groupKey, base);
347
+ // A folded (non-first, currently collapsed) group member renders zero
348
+ // lines of its own — `base` here is just wherever the buffer happens to
349
+ // sit after the last member that DID render, which is the position the
350
+ // NEXT real content starts at, not this message's own position. Anchor
351
+ // it at the group's header line instead, so transcript-event
352
+ // navigation lands on the group rather than skipping past it.
353
+ messageLineRegistry[absoluteIdx] = isFoldedGroupMember(groupMembership, context.collapseState)
354
+ ? (groupHeaderLines.get(groupMembership!.groupKey) ?? base)
355
+ : base;
305
356
 
306
357
  if (!uncacheable) {
307
358
  const existing = this.entries.get(absoluteIdx);
308
- if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, hasPendingTool, context.collapseState)) {
359
+ if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, pendingToolKey, groupMembership, context.collapseState)) {
309
360
  this.applyEntry(context, existing, base);
310
361
  touched.add(absoluteIdx);
311
362
  continue;
312
363
  }
313
364
  }
314
365
 
315
- const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, hasPendingTool);
366
+ const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, pendingToolKey, groupMembership);
316
367
  this.applyEntry(context, entry, base);
317
368
  if (!uncacheable) {
318
369
  this.entries.set(absoluteIdx, entry);
@@ -338,7 +389,8 @@ export class MessageLineCache {
338
389
  cfg: RenderConfig,
339
390
  blockBase: number,
340
391
  kind: SystemMessageKind | undefined,
341
- hasPendingTool: boolean,
392
+ pendingToolKey: string | undefined,
393
+ groupMembership: ToolGroupMembership | undefined,
342
394
  collapseState: Map<string, boolean>,
343
395
  ): boolean {
344
396
  const k = entry.keyMeta;
@@ -351,7 +403,11 @@ export class MessageLineCache {
351
403
  k.showReasoningSummary !== cfg.showReasoningSummary ||
352
404
  k.blockBase !== blockBase ||
353
405
  k.kind !== kind ||
354
- k.hasPendingTool !== hasPendingTool
406
+ k.pendingToolKey !== pendingToolKey ||
407
+ k.groupKey !== groupMembership?.groupKey ||
408
+ k.groupIsFirst !== (groupMembership?.isFirst ?? false) ||
409
+ k.groupToolCount !== (groupMembership?.toolCount ?? 0) ||
410
+ k.groupTotalLines !== (groupMembership?.totalLines ?? 0)
355
411
  ) {
356
412
  return false;
357
413
  }
@@ -376,7 +432,8 @@ export class MessageLineCache {
376
432
  cfg: RenderConfig,
377
433
  blockBase: number,
378
434
  kind: SystemMessageKind | undefined,
379
- hasPendingTool: boolean,
435
+ pendingToolKey: string | undefined,
436
+ groupMembership: ToolGroupMembership | undefined,
380
437
  ): CacheEntry {
381
438
  const scratchLines: Line[] = [];
382
439
  const scratchHistory = {
@@ -401,11 +458,18 @@ export class MessageLineCache {
401
458
  configManager: context.configManager,
402
459
  splashOptions: context.splashOptions,
403
460
  completedToolCallIds: context.completedToolCallIds,
461
+ toolGroupMembership: context.toolGroupMembership,
404
462
  };
405
463
 
406
464
  renderOne(scratchCtx, message, width, absoluteIdx, cfg);
407
- // Trailing blank line, exactly as appendConversationMessages appends per message.
408
- scratchLines.push(createEmptyLine(width));
465
+ // Trailing blank line, exactly as appendConversationMessages appends per
466
+ // message — except a non-owning member of a currently-collapsed
467
+ // tool-result group (see conversation-tool-groups.ts) rendered nothing
468
+ // above and gets no filler line either; every other message keeps the
469
+ // unconditional separator.
470
+ if (!isFoldedGroupMember(groupMembership, context.collapseState)) {
471
+ scratchLines.push(createEmptyLine(width));
472
+ }
409
473
 
410
474
  const collapseDeps: Array<[string, boolean | undefined]> = [];
411
475
  for (const key of readKeys) collapseDeps.push([key, context.collapseState.get(key)]);
@@ -420,7 +484,11 @@ export class MessageLineCache {
420
484
  showReasoningSummary: cfg.showReasoningSummary,
421
485
  blockBase,
422
486
  kind,
423
- hasPendingTool,
487
+ pendingToolKey,
488
+ groupKey: groupMembership?.groupKey,
489
+ groupIsFirst: groupMembership?.isFirst ?? false,
490
+ groupToolCount: groupMembership?.toolCount ?? 0,
491
+ groupTotalLines: groupMembership?.totalLines ?? 0,
424
492
  },
425
493
  contentSig: contentSigOf(message),
426
494
  lines: scratchLines,
@@ -1,6 +1,6 @@
1
1
  import { UIFactory } from '../renderer/ui-factory.ts';
2
2
  import { renderMarkdownTracked } from '../renderer/markdown.ts';
3
- import { renderDiffView } from '../renderer/diff-view.ts';
3
+ import { isDiffContent, renderExpandedToolResultLines } from '../renderer/tool-result-expanded-lines.ts';
4
4
  import { activeTheme, activeUiTones } from '../renderer/theme.ts';
5
5
  import { renderToolCallBlock } from '../renderer/tool-call.ts';
6
6
  import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
@@ -15,6 +15,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
15
  import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
16
16
  import { GLYPHS } from '../renderer/ui-primitives.ts';
17
17
  import type { BlockMeta } from './conversation-types.ts';
18
+ import { computeToolGroupMembership, type ToolGroupMembership } from './conversation-tool-groups.ts';
18
19
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
19
20
  import { parseDiffForApply } from '@pellux/goodvibes-sdk/platform/core';
20
21
  import { extractUserDisplayText, COMPACTION_HANDOFF_HEADER } from '@pellux/goodvibes-sdk/platform/core';
@@ -82,6 +83,13 @@ export interface ConversationRenderContext {
82
83
  * behaviour. (item 2c.)
83
84
  */
84
85
  readonly completedToolCallIds?: ReadonlySet<string>;
86
+ /**
87
+ * Per-tool-message group membership for the currently rendered slice (see
88
+ * conversation-tool-groups.ts). A message index absent from this map (or an
89
+ * undefined map itself) isn't part of a folded run of tool results and
90
+ * renders exactly as it always has.
91
+ */
92
+ readonly toolGroupMembership?: ReadonlyMap<number, ToolGroupMembership>;
85
93
  }
86
94
 
87
95
  export function renderConversationUserMessage(
@@ -199,8 +207,34 @@ export function renderConversationAssistantMessage(
199
207
  const thinkingStartLine = context.history.getLineCount();
200
208
  const thinkingBlockIdx = context.blockRegistry.length;
201
209
  const thinkingCollapseKey = `msg_${msgIdx}_thinking`;
202
- const thinkingLines = renderThinkingBlock(message.reasoningContent, width);
203
- context.history.addLines(thinkingLines);
210
+ // Collapsed by default, like every other collapsible block — Tab (or the
211
+ // block-actions menu) expands it via the same collapseKey the BlockMeta
212
+ // below registers. Previously this key was registered but never
213
+ // consulted: the toggle existed but did nothing.
214
+ const isThinkingCollapsed = context.collapseState.has(thinkingCollapseKey)
215
+ ? context.collapseState.get(thinkingCollapseKey)!
216
+ : true;
217
+ if (!context.collapseState.has(thinkingCollapseKey)) {
218
+ context.collapseState.set(thinkingCollapseKey, true);
219
+ }
220
+ if (isThinkingCollapsed) {
221
+ const thinkingLineCount = message.reasoningContent.split('\n').length;
222
+ const rendered = renderConversationCollapsedFragment(
223
+ `thinking · ${thinkingLineCount} line${thinkingLineCount === 1 ? '' : 's'}`,
224
+ width,
225
+ {
226
+ prefix: ' ▌ ',
227
+ prefixFg: T.reasoningAccent,
228
+ text: '244',
229
+ bodyBg: T.collapsedBodyBg,
230
+ dim: true,
231
+ },
232
+ );
233
+ context.history.addLines(rendered);
234
+ } else {
235
+ const thinkingLines = renderThinkingBlock(message.reasoningContent, width);
236
+ context.history.addLines(thinkingLines);
237
+ }
204
238
  context.history.addLine(createEmptyLine(width));
205
239
  const thinkingRenderedLines = context.history.getLineCount() - thinkingStartLine;
206
240
  context.blockRegistry.push({
@@ -316,6 +350,95 @@ export function renderConversationSystemMessage(
316
350
  }
317
351
  }
318
352
 
353
+ /**
354
+ * True when `message` is a non-owning member of a folded tool-result group
355
+ * (see conversation-tool-groups.ts) that is currently collapsed — it rendered
356
+ * nothing (renderConversationToolMessage returns right after the header-owning
357
+ * member's header line), so the per-message trailing blank-line separator is
358
+ * skipped for it too. The header-owning ("first") member always gets its
359
+ * separator since it always renders at least the header line; any member of
360
+ * an EXPANDED group renders in full and keeps its separator as well.
361
+ */
362
+ export function isFoldedGroupMember(
363
+ membership: ToolGroupMembership | undefined,
364
+ collapseState: ReadonlyMap<string, boolean>,
365
+ ): boolean {
366
+ return membership !== undefined && !membership.isFirst && (collapseState.get(membership.groupKey) ?? true);
367
+ }
368
+
369
+ /**
370
+ * Dedupe a group's per-member tool names into a compact, honest summary —
371
+ * "read×3 exec write" rather than either a bare count or a full repeated
372
+ * list. Truncates with an honest "…+N more" tail rather than silently
373
+ * dropping names once the list gets long.
374
+ */
375
+ function summarizeToolNames(names: readonly string[]): string {
376
+ const counts = new Map<string, number>();
377
+ const order: string[] = [];
378
+ for (const name of names) {
379
+ if (!counts.has(name)) {
380
+ counts.set(name, 0);
381
+ order.push(name);
382
+ }
383
+ counts.set(name, counts.get(name)! + 1);
384
+ }
385
+ const MAX_SHOWN = 4;
386
+ const parts = order.map((name) => (counts.get(name)! > 1 ? `${name}×${counts.get(name)}` : name));
387
+ if (parts.length > MAX_SHOWN) {
388
+ return `${parts.slice(0, MAX_SHOWN).join(' ')} …+${parts.length - MAX_SHOWN} more`;
389
+ }
390
+ return parts.join(' ');
391
+ }
392
+
393
+ /**
394
+ * Render the synthetic header line for a folded run of >=2 tool-result
395
+ * messages: one line + one BlockMeta (type 'tool_group'), shared by every
396
+ * member under `membership.groupKey`. Returns whether the group is currently
397
+ * collapsed, establishing the collapsed-by-default state on first render —
398
+ * mirrors the has/set default-establishment idiom used by every other
399
+ * collapsible block in this file.
400
+ */
401
+ function renderToolGroupHeader(
402
+ context: ConversationRenderContext,
403
+ width: number,
404
+ membership: ToolGroupMembership,
405
+ ): boolean {
406
+ const T = activeTheme();
407
+ const isCollapsed = context.collapseState.has(membership.groupKey)
408
+ ? context.collapseState.get(membership.groupKey)!
409
+ : true;
410
+ if (!context.collapseState.has(membership.groupKey)) {
411
+ context.collapseState.set(membership.groupKey, true);
412
+ }
413
+
414
+ const blockIdx = context.blockRegistry.length;
415
+ const startLine = context.history.getLineCount();
416
+ const toolNamesSummary = summarizeToolNames(membership.toolNames);
417
+ context.history.addLine(renderConversationEventLine(width, {
418
+ marker: GLYPHS.surface.altCursor,
419
+ markerFg: T.toolAccent,
420
+ label: 'tool results',
421
+ labelFg: T.toolAccent,
422
+ detailFg: '244',
423
+ }, [
424
+ { text: ` ${membership.toolCount} tool${membership.toolCount === 1 ? '' : 's'} `, fg: T.toolAccent },
425
+ ...(toolNamesSummary ? [{ text: ` ${toolNamesSummary} `, fg: T.toolNameFg }] : []),
426
+ { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} `, fg: '244', dim: true },
427
+ ]));
428
+
429
+ context.blockRegistry.push({
430
+ blockIndex: blockIdx,
431
+ collapseKey: membership.groupKey,
432
+ type: 'tool_group',
433
+ startLine,
434
+ lineCount: 1,
435
+ rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${toolNamesSummary}, ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
436
+ groupMemberIndexes: membership.memberIndexes,
437
+ });
438
+
439
+ return isCollapsed;
440
+ }
441
+
319
442
  export function renderConversationToolMessage(
320
443
  context: ConversationRenderContext,
321
444
  message: Extract<Message, { role: 'tool' }>,
@@ -323,18 +446,33 @@ export function renderConversationToolMessage(
323
446
  msgIdx: number,
324
447
  ): void {
325
448
  const T = activeTheme();
449
+ const groupMembership = context.toolGroupMembership?.get(msgIdx);
450
+ if (groupMembership) {
451
+ const collapsed = groupMembership.isFirst
452
+ ? renderToolGroupHeader(context, width, groupMembership)
453
+ : (context.collapseState.get(groupMembership.groupKey) ?? true);
454
+ // Folded: the header (rendered above, once, by the first member) is the
455
+ // group's entire visible representation while collapsed — no member,
456
+ // first or not, renders its own header/body/BlockMeta.
457
+ if (collapsed) return;
458
+ }
459
+
326
460
  const collapseKey = `msg_${msgIdx}`;
327
461
  const blockIdx = context.blockRegistry.length;
328
462
  const startLine = context.history.getLineCount();
329
463
  const contentLines = message.content.split('\n');
330
- const lineCount = contentLines.length;
331
- const hasDiffHeader = contentLines.some((l) => l.startsWith('--- ')) && contentLines.some((l) => l.startsWith('+++ '));
332
- const hasHunk = contentLines.some((l) => l.startsWith('@@ '));
333
- const isDiff = hasDiffHeader && hasHunk;
464
+ const isDiff = isDiffContent(message.content);
334
465
  const blockType: 'diff' | 'tool' = isDiff ? 'diff' : 'tool';
335
466
  // Parsed once, ahead of the collapse check, so it's available for the
336
467
  // block-registry meta merge below regardless of collapsed/expanded state.
337
468
  const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
469
+ // The EXPANDED render, computed unconditionally (even while collapsed) so
470
+ // the header's "N lines" badge always names what Tab would actually reveal
471
+ // — a raw JSON blob that pretty-prints to 50 lines must say 50, not 1, even
472
+ // while it's still folded. Reused below as the actual body when expanded,
473
+ // so this is never rendered twice.
474
+ const expandedLines = renderExpandedToolResultLines(message.content, width);
475
+ const lineCount = expandedLines.length;
338
476
 
339
477
  // Human one-line summary for tool results (write/read/exec/edit): shown as the
340
478
  // collapsed line so the transcript reads "wrote foo.txt (532 B)" instead of a
@@ -403,22 +541,11 @@ export function renderConversationToolMessage(
403
541
  dim: true,
404
542
  });
405
543
  context.history.addLines(rendered);
406
- } else if (isDiff) {
407
- // No filename banner: the diff text's own --- / +++ headers already
408
- // identify the file (matches the pre-v0.9.6 call site in tool-call.ts).
409
- context.history.addLines(renderDiffView(message.content, width));
410
544
  } else {
411
- let contentToRender = message.content;
412
- const trimmed = contentToRender.trimStart();
413
- if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && contentToRender.length < 100_000) {
414
- try {
415
- const parsed = JSON.parse(contentToRender);
416
- contentToRender = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
417
- } catch {
418
- // Leave invalid JSON as-is.
419
- }
420
- }
421
- context.history.addLines(renderMarkdownTracked(contentToRender, width).lines);
545
+ // Diff or plain result — either way this is exactly `expandedLines`,
546
+ // already computed above (and already what the header's line count is
547
+ // honest about), so there is nothing left to render here.
548
+ context.history.addLines(expandedLines);
422
549
  }
423
550
 
424
551
  const renderedLineCount = context.history.getLineCount() - startLine;
@@ -429,6 +556,7 @@ export function renderConversationToolMessage(
429
556
  startLine,
430
557
  lineCount: renderedLineCount,
431
558
  rawContent: message.content,
559
+ toolName: message.toolName,
432
560
  };
433
561
 
434
562
  if (isDiff && diffParse) {
@@ -458,23 +586,53 @@ export function appendConversationMessages(
458
586
  const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
459
587
  ? context
460
588
  : { ...context, completedToolCallIds: collectCompletedToolCallIds(messages) };
589
+ // Fold runs of >=2 consecutive tool-result messages sharing one assistant
590
+ // turn under a single collapsible header (see conversation-tool-groups.ts),
591
+ // unless the caller already supplied membership (the line-cache path
592
+ // computes it once per renderInto call and threads it through).
593
+ const toolGroupMembership = renderContext.toolGroupMembership
594
+ ?? computeToolGroupMembership(messages, msgIndexOffset, width);
595
+ const groupedContext: ConversationRenderContext = renderContext.toolGroupMembership !== undefined
596
+ ? renderContext
597
+ : { ...renderContext, toolGroupMembership };
598
+
599
+ // Header line of each currently-open tool-group, keyed by groupKey — the
600
+ // first member's own registered line, recorded as that member is reached
601
+ // below so later (non-first) members of the SAME group can resolve to it
602
+ // instead of whatever position the buffer happens to be at when a folded
603
+ // (zero-line) member is processed. See isFoldedGroupMember's doc.
604
+ const groupHeaderLines = new Map<string, number>();
461
605
 
462
606
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
463
607
  const message = messages[msgIdx];
464
608
  // absoluteIdx aligns the slice-relative loop counter with the absolute
465
609
  // message index used as the key in messageKindRegistry and messageLineRegistry.
466
610
  const absoluteIdx = msgIndexOffset + msgIdx;
467
- messageLineRegistry[absoluteIdx] = renderContext.history.getLineCount();
611
+ const membership = toolGroupMembership.get(absoluteIdx);
612
+ const currentLine = groupedContext.history.getLineCount();
613
+ if (membership?.isFirst) groupHeaderLines.set(membership.groupKey, currentLine);
614
+ // A folded (non-first, currently collapsed) group member renders zero
615
+ // lines of its own — `currentLine` here is just wherever the buffer
616
+ // happens to sit after the last member that DID render, which is the
617
+ // position the NEXT real content starts at, not this message's own
618
+ // position. Anchor it at the group's header line instead, so
619
+ // transcript-event navigation lands on the group rather than skipping
620
+ // past it to the following message.
621
+ messageLineRegistry[absoluteIdx] = isFoldedGroupMember(membership, groupedContext.collapseState)
622
+ ? (groupHeaderLines.get(membership!.groupKey) ?? currentLine)
623
+ : currentLine;
468
624
  if (message.role === 'user') {
469
- renderConversationUserMessage(renderContext, message, width, absoluteIdx);
625
+ renderConversationUserMessage(groupedContext, message, width, absoluteIdx);
470
626
  } else if (message.role === 'assistant') {
471
- renderConversationAssistantMessage(renderContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
627
+ renderConversationAssistantMessage(groupedContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
472
628
  } else if (message.role === 'system') {
473
- renderConversationSystemMessage(renderContext, message, width, absoluteIdx);
629
+ renderConversationSystemMessage(groupedContext, message, width, absoluteIdx);
474
630
  } else if (message.role === 'tool') {
475
- renderConversationToolMessage(renderContext, message, width, absoluteIdx);
631
+ renderConversationToolMessage(groupedContext, message, width, absoluteIdx);
632
+ }
633
+ if (!isFoldedGroupMember(membership, groupedContext.collapseState)) {
634
+ groupedContext.history.addLine(createEmptyLine(width));
476
635
  }
477
- renderContext.history.addLine(createEmptyLine(width));
478
636
  }
479
637
  }
480
638
 
@@ -0,0 +1,152 @@
1
+ /**
2
+ * conversation-tool-groups.ts — detects runs of consecutive tool-result
3
+ * messages that belong to one assistant turn, so the transcript can fold them
4
+ * under a single collapsible group header instead of rendering one
5
+ * "tool result" header per call. Before this module, an assistant turn with
6
+ * N tool calls produced N independent header+body blocks in a row — visual
7
+ * spam once N is more than one or two.
8
+ *
9
+ * A run only counts as a group when it has >= 2 matched tool-result messages:
10
+ * a single result renders exactly as it always has (see
11
+ * renderConversationToolMessage in conversation-rendering.ts, which only
12
+ * consults this module's output when a membership entry exists).
13
+ *
14
+ * Pure function of the message slice — no rendering, no width, no side
15
+ * effects. Called once per render pass (appendConversationMessages /
16
+ * MessageLineCache.renderInto) over the currently-visible message slice, with
17
+ * the absolute index of the slice's first message so group keys and member
18
+ * indexes line up with the same collapseKey / blockRegistry indices a cold
19
+ * render would produce (messages may be a post-clearDisplay slice, not the
20
+ * full snapshot — see appendConversationMessages's msgIndexOffset doc).
21
+ */
22
+
23
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
24
+ import { renderExpandedToolResultLines } from '../renderer/tool-result-expanded-lines.ts';
25
+
26
+ type Message = ConversationMessageSnapshot;
27
+
28
+ /** One consolidated run of tool-result messages sharing one assistant turn. */
29
+ export interface ToolGroup {
30
+ /** Absolute index of the assistant message that owns this group. */
31
+ readonly assistantIdx: number;
32
+ /** Absolute indexes of the matched tool-result messages, in order. */
33
+ readonly toolMessageIndexes: readonly number[];
34
+ }
35
+
36
+ /**
37
+ * Per-tool-message membership, keyed by the ABSOLUTE index of the tool-result
38
+ * message (the owning assistant message is never a key — only its tool-result
39
+ * children are). `isFirst` marks the member whose render site owns the
40
+ * group's header line and BlockMeta; every other member renders nothing while
41
+ * the group is collapsed (see renderConversationToolMessage).
42
+ */
43
+ export interface ToolGroupMembership {
44
+ readonly groupKey: string;
45
+ readonly isFirst: boolean;
46
+ /** Matched tool-result messages in the group (the header's "N tools"). */
47
+ readonly toolCount: number;
48
+ /** Sum of each member's EXPANDED render line count (the header's "N lines") —
49
+ * the same post-render count each member's own "N lines" badge shows, so
50
+ * the group total matches what expanding every member actually reveals,
51
+ * not a raw-content estimate (a folded JSON result can pretty-print to far
52
+ * more lines than its raw content has). */
53
+ readonly totalLines: number;
54
+ /** Each member's tool name, in order (falls back to 'tool' when a result
55
+ * carries no name) — the header dedupes these into e.g. "read×3 exec". */
56
+ readonly toolNames: readonly string[];
57
+ /**
58
+ * Absolute indexes of every tool-result message in the group, in order —
59
+ * the same list for every member. Carried onto the group's BlockMeta so a
60
+ * single expand pass can also open each member's own collapse key
61
+ * (`msg_<idx>`), since a folded member pushes no BlockMeta of its own to
62
+ * toggle individually (see local-runtime.ts's toggleBlocks).
63
+ */
64
+ readonly memberIndexes: readonly number[];
65
+ }
66
+
67
+ /**
68
+ * Scan `messages` for assistant-message runs of >=2 consecutive matching
69
+ * tool-result messages. A run breaks on the first message that isn't a
70
+ * matching tool result: any non-tool message (interleaved user/system/
71
+ * assistant content), or a tool message whose callId isn't one of the
72
+ * assistant's own toolCalls ids (a result belonging to a different turn never
73
+ * gets folded into a group it doesn't belong to).
74
+ */
75
+ export function detectToolGroups(
76
+ messages: readonly Message[],
77
+ indexOffset = 0,
78
+ ): ToolGroup[] {
79
+ const groups: ToolGroup[] = [];
80
+
81
+ for (let i = 0; i < messages.length; i++) {
82
+ const message = messages[i];
83
+ if (message.role !== 'assistant' || !message.toolCalls || message.toolCalls.length === 0) continue;
84
+ const callIds = new Set(
85
+ message.toolCalls
86
+ .map((tc) => tc.id)
87
+ .filter((id): id is string => id !== undefined),
88
+ );
89
+ if (callIds.size === 0) continue;
90
+
91
+ const toolMessageIndexes: number[] = [];
92
+ let j = i + 1;
93
+ while (j < messages.length) {
94
+ const next = messages[j];
95
+ if (next.role !== 'tool' || !next.callId || !callIds.has(next.callId)) break;
96
+ toolMessageIndexes.push(indexOffset + j);
97
+ j++;
98
+ }
99
+
100
+ if (toolMessageIndexes.length >= 2) {
101
+ groups.push({ assistantIdx: indexOffset + i, toolMessageIndexes });
102
+ }
103
+ }
104
+
105
+ return groups;
106
+ }
107
+
108
+ /** Build the per-tool-message membership lookup used by the renderer/cache.
109
+ * `width` must match the width the transcript is actually rendered at — it
110
+ * feeds the same expanded-render line count each member's own badge uses
111
+ * (see renderExpandedToolResultLines), so the group total is never computed
112
+ * against a different width than what the member badges show. */
113
+ export function buildToolGroupMembership(
114
+ groups: readonly ToolGroup[],
115
+ messages: readonly Message[],
116
+ indexOffset: number,
117
+ width: number,
118
+ ): ReadonlyMap<number, ToolGroupMembership> {
119
+ const membership = new Map<number, ToolGroupMembership>();
120
+ for (const group of groups) {
121
+ const groupKey = `group_${group.assistantIdx}`;
122
+ let totalLines = 0;
123
+ const toolNames: string[] = [];
124
+ for (const absIdx of group.toolMessageIndexes) {
125
+ const message = messages[absIdx - indexOffset];
126
+ if (message && message.role === 'tool') {
127
+ totalLines += renderExpandedToolResultLines(message.content, width).length;
128
+ toolNames.push(message.toolName ?? 'tool');
129
+ }
130
+ }
131
+ group.toolMessageIndexes.forEach((absIdx, idx) => {
132
+ membership.set(absIdx, {
133
+ groupKey,
134
+ isFirst: idx === 0,
135
+ toolCount: group.toolMessageIndexes.length,
136
+ totalLines,
137
+ toolNames,
138
+ memberIndexes: group.toolMessageIndexes,
139
+ });
140
+ });
141
+ }
142
+ return membership;
143
+ }
144
+
145
+ /** Convenience wrapper: detect groups, then build the membership map in one call. */
146
+ export function computeToolGroupMembership(
147
+ messages: readonly Message[],
148
+ indexOffset = 0,
149
+ width = 80,
150
+ ): ReadonlyMap<number, ToolGroupMembership> {
151
+ return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset, width);
152
+ }