@pellux/goodvibes-tui 1.21.0 → 1.22.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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +714 -177
  4. package/docs/foundation-artifacts/peer-contract.json +8 -4
  5. package/package.json +9 -7
  6. package/src/cli/entrypoint.ts +6 -0
  7. package/src/cli/service-posture.ts +6 -2
  8. package/src/config/run-daemon-config-migration.ts +46 -0
  9. package/src/core/conversation-compaction-render.ts +88 -0
  10. package/src/core/conversation-line-cache.ts +120 -92
  11. package/src/core/conversation-rendering.ts +290 -233
  12. package/src/core/conversation-turn-structure.ts +464 -0
  13. package/src/core/conversation-types.ts +8 -9
  14. package/src/daemon/cli.ts +71 -5
  15. package/src/input/commands/local-runtime.ts +14 -14
  16. package/src/input/commands/operator-rpc.ts +15 -4
  17. package/src/input/daemon-owned-settings-descriptions.ts +53 -0
  18. package/src/input/handler-content-actions.ts +4 -2
  19. package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
  20. package/src/input/onboarding/onboarding-feature-units.ts +38 -0
  21. package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
  22. package/src/input/search.ts +1 -1
  23. package/src/input/settings-modal-data.ts +15 -85
  24. package/src/input/settings-modal-search.ts +99 -0
  25. package/src/input/settings-modal-types.ts +3 -1
  26. package/src/renderer/conversation-surface.ts +37 -11
  27. package/src/renderer/conversation-tree.ts +179 -0
  28. package/src/renderer/settings-modal-helpers.ts +56 -2
  29. package/src/renderer/settings-modal.ts +9 -50
  30. package/src/renderer/tool-call.ts +38 -5
  31. package/src/runtime/cloudflare-control-plane.ts +4 -1
  32. package/src/runtime/feature-settings.ts +7 -0
  33. package/src/runtime/index.ts +22 -1
  34. package/src/runtime/legacy-daemon-migration.ts +2 -0
  35. package/src/verification/verification-ledger.ts +115 -2
  36. package/src/version.ts +1 -1
  37. package/src/core/conversation-tool-groups.ts +0 -152
@@ -10,12 +10,21 @@ import { renderSystemMessage } from '../renderer/system-message.ts';
10
10
  import { createEmptyLine, type Line, type Cell } from '../types/grid.ts';
11
11
  import { getSplashLines, type SplashOptions } from '../utils/splash-lines.ts';
12
12
  import { interpolateColor, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
- import { LAYOUT } from '../renderer/layout.ts';
13
+ import { LAYOUT, TOOL_STATUS } from '../renderer/layout.ts';
14
14
  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
+ import { renderCompactionContinuationMessage } from './conversation-compaction-render.ts';
19
+ import { drawBranchConnector, treeIndentCols, writeTreeStatusGutter } from '../renderer/conversation-tree.ts';
20
+ import {
21
+ MAX_NEST_DEPTH,
22
+ buildRenderPlan,
23
+ collapseKeyForNode,
24
+ computeAssistantTurns,
25
+ type AssistantTurnMembership,
26
+ type RenderNode,
27
+ } from './conversation-turn-structure.ts';
19
28
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
20
29
  import { parseDiffForApply } from '@pellux/goodvibes-sdk/platform/core';
21
30
  import { extractUserDisplayText, COMPACTION_HANDOFF_HEADER } from '@pellux/goodvibes-sdk/platform/core';
@@ -84,12 +93,33 @@ export interface ConversationRenderContext {
84
93
  */
85
94
  readonly completedToolCallIds?: ReadonlySet<string>;
86
95
  /**
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.
96
+ * Which assistant messages share one `● assistant` header, and what that
97
+ * header must say (see conversation-turn-structure.ts). Keyed by absolute
98
+ * index for every assistant message in a run AND every tool-result message
99
+ * the run's calls produced, so a collapsed turn can hide its results too.
100
+ * A message absent from this map (or an undefined map) renders standalone,
101
+ * exactly as it did before turn merging.
91
102
  */
92
- readonly toolGroupMembership?: ReadonlyMap<number, ToolGroupMembership>;
103
+ readonly assistantTurns?: ReadonlyMap<number, AssistantTurnMembership>;
104
+ /**
105
+ * Live snapshot reader for a spawned agent's own conversation, used to
106
+ * splice that agent's rows in beneath the call that spawned it (see
107
+ * conversation-turn-structure.ts). Undefined disables nesting entirely and
108
+ * the transcript renders exactly as it would without subagents.
109
+ */
110
+ readonly resolveAgentSnapshot?: (agentId: string) => readonly Message[] | null;
111
+ }
112
+
113
+ /**
114
+ * Whether a turn's branches are hidden. Turns default to EXPANDED (unlike
115
+ * every other collapsible block here, which defaults to collapsed): a turn
116
+ * collapsed by default would hide the activity the transcript exists to show.
117
+ */
118
+ export function isTurnCollapsed(
119
+ turn: AssistantTurnMembership | undefined,
120
+ collapseState: ReadonlyMap<string, boolean>,
121
+ ): boolean {
122
+ return turn !== undefined && (collapseState.get(turn.turnKey) ?? false);
93
123
  }
94
124
 
95
125
  export function renderConversationUserMessage(
@@ -116,62 +146,6 @@ export function renderConversationUserMessage(
116
146
  context.history.addLines(UIFactory.createMessageBar(width, displayText));
117
147
  }
118
148
 
119
- /** Fold a compaction-continuation user message to one header + preview line. */
120
- function renderCompactionContinuationMessage(
121
- context: ConversationRenderContext,
122
- content: string,
123
- width: number,
124
- msgIdx: number,
125
- ): void {
126
- const T = activeTheme();
127
- const collapseKey = `msg_${msgIdx}`;
128
- const blockIdx = context.blockRegistry.length;
129
- const startLine = context.history.getLineCount();
130
- const lineCount = content.split('\n').length;
131
- const isCollapsed = context.collapseState.has(collapseKey)
132
- ? context.collapseState.get(collapseKey)!
133
- : true;
134
- if (!context.collapseState.has(collapseKey)) {
135
- context.collapseState.set(collapseKey, true);
136
- }
137
-
138
- context.history.addLine(renderConversationEventLine(width, {
139
- marker: GLYPHS.status.active,
140
- markerFg: T.toolAccent,
141
- label: 'compaction handoff',
142
- labelFg: T.toolAccent,
143
- detailFg: '244',
144
- }, [
145
- { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
146
- ]));
147
-
148
- if (isCollapsed) {
149
- const rendered = renderConversationCollapsedFragment(
150
- 'compacted-context handoff (re-injected instructions + session summary)',
151
- width,
152
- {
153
- prefix: ` ${GLYPHS.navigation.collapsed} `,
154
- prefixFg: T.toolAccent,
155
- text: '244',
156
- bodyBg: T.collapsedBodyBg,
157
- dim: true,
158
- },
159
- );
160
- context.history.addLines(rendered);
161
- } else {
162
- context.history.addLines(renderMarkdownTracked(content, width).lines);
163
- }
164
-
165
- context.blockRegistry.push({
166
- blockIndex: blockIdx,
167
- collapseKey,
168
- type: 'tool',
169
- startLine,
170
- lineCount: context.history.getLineCount() - startLine,
171
- rawContent: content,
172
- });
173
- }
174
-
175
149
  export function renderConversationAssistantMessage(
176
150
  context: ConversationRenderContext,
177
151
  message: Extract<Message, { role: 'assistant' }>,
@@ -181,24 +155,90 @@ export function renderConversationAssistantMessage(
181
155
  msgIdx: number,
182
156
  ): void {
183
157
  const T = activeTheme();
184
- const assistantHeaderDetails = [];
185
- if (message.model) {
186
- assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
187
- }
188
- if (message.toolCalls && message.toolCalls.length > 0) {
189
- assistantHeaderDetails.push({ text: ` ${GLYPHS.status.pending} tools:${message.toolCalls.length} `, fg: T.toolAccent });
190
- }
191
- if (message.reasoningContent || message.reasoningSummary) {
192
- assistantHeaderDetails.push({ text: ` ${GLYPHS.status.active} reasoning `, fg: T.reasoningAccent, dim: true });
158
+ const turn = context.assistantTurns?.get(msgIdx);
159
+ // No membership (a standalone render, e.g. a golden-frame harness) behaves
160
+ // exactly as it always did: the message owns its own header.
161
+ const isHead = turn?.isHead ?? true;
162
+ const turnCollapsed = isTurnCollapsed(turn, context.collapseState);
163
+
164
+ if (isHead) {
165
+ const assistantHeaderDetails = [];
166
+ if (message.model) {
167
+ assistantHeaderDetails.push({ text: ` ${message.model}${message.provider ? ` (${message.provider})` : ''} `, fg: T.modelNameDim, dim: true });
168
+ }
169
+ // The count spans the whole run, not just this message — that is the point
170
+ // of merging. `tools:1` repeated over five headers becomes one `5 tools`.
171
+ const toolCount = turn?.toolCallCount ?? message.toolCalls?.length ?? 0;
172
+ if (toolCount > 0) {
173
+ assistantHeaderDetails.push({ text: ` ${GLYPHS.status.pending} ${toolCount} tool${toolCount === 1 ? '' : 's'} `, fg: T.toolAccent });
174
+ }
175
+ // A label every call in the run shares belongs here, once, instead of on
176
+ // every branch — the branches then lead with what distinguishes them.
177
+ //
178
+ // Hoisted ONLY when it fits whole. A label chopped mid-word ("Calling the
179
+ // assistant serv") is worse than no label: it costs a third of the header
180
+ // and tells you less than the rows below it already do, since each row
181
+ // leads with its own distinguishing token. So it either fits or it goes —
182
+ // never a truncated stub. Dropping it loses nothing reachable: the rows
183
+ // still name every call, and omitToolName is unaffected either way.
184
+ if (turn?.sharedToolLabel) {
185
+ const usedCols = assistantHeaderDetails.reduce((sum, seg) => sum + getDisplayWidth(seg.text), 0);
186
+ const availCols = width - LAYOUT.RIGHT_MARGIN - (LAYOUT.LEFT_MARGIN + 1)
187
+ - getDisplayWidth(' assistant ') - usedCols;
188
+ const labelText = ` ${turn.sharedToolLabel} `;
189
+ if (getDisplayWidth(labelText) <= availCols) {
190
+ assistantHeaderDetails.push({ text: labelText, fg: T.toolNameFg });
191
+ }
192
+ }
193
+ // Aggregated across the run so suppressing a non-head's header never loses
194
+ // the signal that reasoning happened.
195
+ const hasReasoning = turn?.hasReasoning ?? Boolean(message.reasoningContent || message.reasoningSummary);
196
+ if (hasReasoning) {
197
+ assistantHeaderDetails.push({ text: ` ${GLYPHS.status.active} reasoning `, fg: T.reasoningAccent, dim: true });
198
+ }
199
+ if (turnCollapsed && toolCount > 0) {
200
+ assistantHeaderDetails.push({ text: ` ${GLYPHS.navigation.collapsed} hidden `, fg: '244', dim: true });
201
+ }
202
+ // An empty run — no model, no tools, no reasoning — emits no header rather
203
+ // than a bare `● assistant` with nothing under it.
204
+ if (assistantHeaderDetails.length > 0) {
205
+ const headerStartLine = context.history.getLineCount();
206
+ const headerBlockIdx = context.blockRegistry.length;
207
+ context.history.addLine(renderConversationEventLine(width, {
208
+ marker: GLYPHS.status.active,
209
+ markerFg: T.assistantHeader,
210
+ label: 'assistant',
211
+ labelFg: T.assistantHeader,
212
+ detailFg: '244',
213
+ }, assistantHeaderDetails));
214
+
215
+ // Turns are collapsible as a unit and default to EXPANDED — a turn
216
+ // collapsed by default would hide the activity the transcript exists to
217
+ // show. Registered only when there is machinery to hide.
218
+ if (turn && toolCount > 0) {
219
+ if (!context.collapseState.has(turn.turnKey)) {
220
+ context.collapseState.set(turn.turnKey, false);
221
+ }
222
+ context.blockRegistry.push({
223
+ blockIndex: headerBlockIdx,
224
+ collapseKey: turn.turnKey,
225
+ type: 'assistant_turn',
226
+ startLine: headerStartLine,
227
+ lineCount: 1,
228
+ rawContent: `assistant turn — ${toolCount} tool call${toolCount === 1 ? '' : 's'}${turn.sharedToolLabel ? ` (${turn.sharedToolLabel})` : ''}`,
229
+ groupMemberIndexes: turn.resultIndexes,
230
+ toolName: turn.sharedToolLabel,
231
+ });
232
+ }
233
+ }
193
234
  }
194
- if (assistantHeaderDetails.length > 0) {
195
- context.history.addLine(renderConversationEventLine(width, {
196
- marker: GLYPHS.status.active,
197
- markerFg: T.assistantHeader,
198
- label: 'assistant',
199
- labelFg: T.assistantHeader,
200
- detailFg: '244',
201
- }, assistantHeaderDetails));
235
+
236
+ // A collapsed turn hides its machinery (tool rows, reasoning) but never its
237
+ // prose: the prose IS the answer, and hiding it would make collapse
238
+ // destructive rather than tidying.
239
+ if (turnCollapsed) {
240
+ if (message.content) renderAssistantProse(context, message, width, lineNumberMode, collapseThreshold, msgIdx);
241
+ return;
202
242
  }
203
243
 
204
244
  const showThinking = context.configManager?.get('display.showThinking') ?? false;
@@ -252,7 +292,27 @@ export function renderConversationAssistantMessage(
252
292
  context.history.addLine(createEmptyLine(width));
253
293
  }
254
294
 
255
- if (message.content) {
295
+ if (message.content) renderAssistantProse(context, message, width, lineNumberMode, collapseThreshold, msgIdx);
296
+ }
297
+
298
+ /**
299
+ * Assistant prose — the model's actual answer — at full prominence and full
300
+ * width.
301
+ *
302
+ * Prose is never drawn as a tree branch. It is what closes a group (see
303
+ * startsNewTurn) and the content the tree exists to surround, not a child of
304
+ * it; indenting it would also re-wrap markdown at a reduced width, which is
305
+ * the table-drops-columns hazard.
306
+ */
307
+ function renderAssistantProse(
308
+ context: ConversationRenderContext,
309
+ message: Extract<Message, { role: 'assistant' }>,
310
+ width: number,
311
+ lineNumberMode: 'all' | 'code' | 'off',
312
+ collapseThreshold: number,
313
+ msgIdx: number,
314
+ ): void {
315
+ {
256
316
  const showAllLineNumbers = lineNumberMode === 'all';
257
317
  const showCodeBlockLineNumbers = lineNumberMode === 'all' ? false : lineNumberMode === 'code';
258
318
  // First pass: measure totalLines for gutter sizing (only when line-numbers='all').
@@ -322,15 +382,6 @@ export function renderConversationAssistantMessage(
322
382
  }
323
383
  }
324
384
 
325
- if (message.toolCalls && message.toolCalls.length > 0) {
326
- for (const tc of message.toolCalls) {
327
- // A tool call with no result message yet is still awaiting a decision
328
- // (e.g. approval) — show a pending glyph, not the completed ✓.
329
- const ran = context.completedToolCallIds === undefined
330
- || (tc.id !== undefined && context.completedToolCallIds.has(tc.id));
331
- context.history.addLines(renderToolCallBlock(tc, ran ? 'done' : 'pending', undefined, width));
332
- }
333
- }
334
385
  }
335
386
 
336
387
  export function renderConversationSystemMessage(
@@ -351,92 +402,64 @@ export function renderConversationSystemMessage(
351
402
  }
352
403
 
353
404
  /**
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.
405
+ * Render one tool-call row as a branch of its turn.
406
+ *
407
+ * The call row is its own plan node rather than part of the assistant
408
+ * message's render, which is what lets a result be interleaved BETWEEN two
409
+ * calls of the same message — the ordering guarantee that makes a
410
+ * late-finishing call's result land inside its own subtree instead of after a
411
+ * call issued later.
400
412
  */
401
- function renderToolGroupHeader(
413
+ export function renderConversationToolCallNode(
402
414
  context: ConversationRenderContext,
415
+ node: RenderNode,
403
416
  width: number,
404
- membership: ToolGroupMembership,
405
- ): boolean {
417
+ ): void {
406
418
  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
- });
419
+ const message = node.message;
420
+ if (message.role !== 'assistant') return;
421
+ const call = message.toolCalls?.[node.callIndex ?? 0];
422
+ if (!call) return;
423
+
424
+ const turn = context.assistantTurns?.get(node.absIdx);
425
+ if (isTurnCollapsed(turn, context.collapseState)) return;
426
+
427
+ const ran = context.completedToolCallIds === undefined
428
+ || (call.id !== undefined && context.completedToolCallIds.has(call.id));
429
+ const indent = treeIndentCols(node.depth, width);
430
+ const lines = renderToolCallBlock(
431
+ call,
432
+ ran ? 'done' : 'pending',
433
+ undefined,
434
+ width,
435
+ undefined,
436
+ undefined,
437
+ undefined,
438
+ undefined,
439
+ { indentCols: indent, omitToolName: turn?.sharedToolLabel !== undefined },
440
+ );
441
+ const line = lines[0];
442
+ if (line) drawBranchConnector(line, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
443
+ context.history.addLines(lines);
438
444
 
439
- return isCollapsed;
445
+ // Recursion stopped here — say so rather than silently showing a subtree as
446
+ // if it were a leaf.
447
+ if (node.truncated) {
448
+ const noteIndent = treeIndentCols(node.depth + 1, width);
449
+ context.history.addLine(renderConversationEventLine(width, {
450
+ marker: GLYPHS.navigation.collapsed,
451
+ markerFg: '244',
452
+ label: '',
453
+ labelFg: '244',
454
+ detailFg: '244',
455
+ }, [{
456
+ text: node.truncated === 'depth'
457
+ ? ` nested activity continues below depth ${MAX_NEST_DEPTH} `
458
+ : ' nested activity repeats an agent already shown above ',
459
+ fg: '244',
460
+ dim: true,
461
+ }], noteIndent));
462
+ }
440
463
  }
441
464
 
442
465
  export function renderConversationToolMessage(
@@ -444,20 +467,16 @@ export function renderConversationToolMessage(
444
467
  message: Extract<Message, { role: 'tool' }>,
445
468
  width: number,
446
469
  msgIdx: number,
470
+ node?: RenderNode,
447
471
  ): void {
448
472
  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
- }
473
+ const depth = node?.depth ?? 0;
474
+ const indent = treeIndentCols(depth, width);
475
+ const turn = context.assistantTurns?.get(msgIdx);
476
+ // A collapsed turn hides its machinery; the head's prose stays visible.
477
+ if (isTurnCollapsed(turn, context.collapseState)) return;
459
478
 
460
- const collapseKey = `msg_${msgIdx}`;
479
+ const collapseKey = node ? collapseKeyForNode(node) : `msg_${msgIdx}`;
461
480
  const blockIdx = context.blockRegistry.length;
462
481
  const startLine = context.history.getLineCount();
463
482
  const contentLines = message.content.split('\n');
@@ -504,22 +523,46 @@ export function renderConversationToolMessage(
504
523
  const isCancelled = blockType === 'tool' && /^Error: cancelled by user\b/.test(contentLines[0] ?? '');
505
524
  const warnTone = activeUiTones().chrome.warn;
506
525
 
507
- context.history.addLine(renderConversationEventLine(width, {
526
+ // In the tree a result hangs under the call that produced it, so repeating
527
+ // the generic label ("tool result") and the tool's own name — which the
528
+ // parent call row already shows — is exactly the boilerplate this layout
529
+ // removes. The row leads with its size badge instead. `diff` and `cancelled`
530
+ // keep their labels: those carry information the parent row does not.
531
+ const inTree = depth > 0 && indent > 0;
532
+ const label = isCancelled ? 'cancelled' : (blockType === 'diff' ? 'diff' : (inTree ? '' : 'tool result'));
533
+ const nameSegments = inTree
534
+ ? []
535
+ : (message.toolName
536
+ ? [{ text: ` ${message.toolName} `, fg: T.toolNameFg }]
537
+ : [{ text: ` ${summarizeCallId(message.callId || 'standalone')} `, fg: '244' as const, dim: true }]);
538
+
539
+ const headerLine = renderConversationEventLine(width, {
508
540
  marker: isCancelled ? GLYPHS.status.blocked : (blockType === 'diff' ? GLYPHS.status.dualPane : GLYPHS.status.active),
509
541
  markerFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
510
- label: isCancelled ? 'cancelled' : (blockType === 'diff' ? 'diff' : 'tool result'),
542
+ label,
511
543
  labelFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
512
544
  detailFg: '244',
513
545
  }, [
514
- ...(message.toolName
515
- ? [{ text: ` ${message.toolName} `, fg: T.toolNameFg }]
516
- : [{ text: ` ${summarizeCallId(message.callId || 'standalone')} `, fg: '244' as const, dim: true }]),
546
+ ...nameSegments,
517
547
  { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
518
- ]));
548
+ ], indent);
549
+ if (node && inTree) {
550
+ drawBranchConnector(headerLine, node.depth, node.connector, node.openAncestorDepths, width, T.toolAccent);
551
+ // A settled result means its call finished: ✓ in the shared gutter, or the
552
+ // blocked glyph when the user cancelled it. Distinguished by GLYPH, not by
553
+ // colour alone.
554
+ writeTreeStatusGutter(
555
+ headerLine,
556
+ isCancelled ? GLYPHS.status.blocked : TOOL_STATUS.SUCCESS_ICON,
557
+ isCancelled ? warnTone : activeUiTones().chrome.good,
558
+ width,
559
+ );
560
+ }
561
+ context.history.addLine(headerLine);
519
562
 
520
563
  if (isCollapsed) {
521
564
  const collapseSuffixReserve = 30;
522
- const avail = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - collapseSuffixReserve);
565
+ const avail = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - indent - collapseSuffixReserve);
523
566
  const hiddenCount = lineCount - 1;
524
567
  let collapsedText: string;
525
568
  if (resultSummary !== null) {
@@ -539,12 +582,19 @@ export function renderConversationToolMessage(
539
582
  text: '244',
540
583
  bodyBg: T.collapsedBodyBg,
541
584
  dim: true,
585
+ indentCols: indent,
542
586
  });
543
587
  context.history.addLines(rendered);
544
588
  } else {
545
589
  // Diff or plain result — either way this is exactly `expandedLines`,
546
590
  // already computed above (and already what the header's line count is
547
591
  // honest about), so there is nothing left to render here.
592
+ //
593
+ // Deliberately NOT indented. renderExpandedToolResultLines is the single
594
+ // source of truth for the "N lines" badge above; re-wrapping it at a
595
+ // reduced width would desynchronise that count from what expansion
596
+ // actually reveals — the markdown-table-drops-columns bug class. The body
597
+ // is already visually bound to its row by the header directly above it.
548
598
  context.history.addLines(expandedLines);
549
599
  }
550
600
 
@@ -586,52 +636,59 @@ export function appendConversationMessages(
586
636
  const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
587
637
  ? context
588
638
  : { ...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
639
+ const assistantTurns = renderContext.assistantTurns
640
+ ?? computeAssistantTurns(messages, msgIndexOffset);
641
+ const turnContext: ConversationRenderContext = renderContext.assistantTurns !== undefined
596
642
  ? 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>();
605
-
606
- for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
607
- const message = messages[msgIdx];
608
- // absoluteIdx aligns the slice-relative loop counter with the absolute
609
- // message index used as the key in messageKindRegistry and messageLineRegistry.
610
- const absoluteIdx = msgIndexOffset + msgIdx;
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;
624
- if (message.role === 'user') {
625
- renderConversationUserMessage(groupedContext, message, width, absoluteIdx);
626
- } else if (message.role === 'assistant') {
627
- renderConversationAssistantMessage(groupedContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
628
- } else if (message.role === 'system') {
629
- renderConversationSystemMessage(groupedContext, message, width, absoluteIdx);
630
- } else if (message.role === 'tool') {
631
- renderConversationToolMessage(groupedContext, message, width, absoluteIdx);
643
+ : { ...renderContext, assistantTurns };
644
+
645
+ const plan = buildRenderPlan(messages, msgIndexOffset, {
646
+ resolveAgentSnapshot: context.resolveAgentSnapshot,
647
+ });
648
+
649
+ // Header line of each turn, so a row that renders nothing (hidden by a
650
+ // collapsed turn) still anchors transcript navigation at its turn rather
651
+ // than at whatever position the buffer happens to sit at.
652
+ const turnHeaderLines = new Map<string, number>();
653
+
654
+ for (let i = 0; i < plan.length; i++) {
655
+ const node = plan[i]!;
656
+ const before = turnContext.history.getLineCount();
657
+
658
+ if (node.kind === 'toolcall') {
659
+ renderConversationToolCallNode(turnContext, node, width);
660
+ } else {
661
+ const message = node.message;
662
+ // Nested rows index into their own agent's snapshot, so they must not
663
+ // write the root transcript's line registry.
664
+ const isRoot = node.scope === '';
665
+ const turn = turnContext.assistantTurns?.get(node.absIdx);
666
+ if (turn?.isHead) turnHeaderLines.set(turn.turnKey, before);
667
+ if (isRoot) {
668
+ messageLineRegistry[node.absIdx] = turn && isTurnCollapsed(turn, turnContext.collapseState)
669
+ ? (turnHeaderLines.get(turn.turnKey) ?? before)
670
+ : before;
671
+ }
672
+
673
+ if (message.role === 'user') {
674
+ renderConversationUserMessage(turnContext, message, width, node.absIdx);
675
+ } else if (message.role === 'assistant') {
676
+ renderConversationAssistantMessage(turnContext, message, width, lineNumberMode, collapseThreshold, node.absIdx);
677
+ } else if (message.role === 'system') {
678
+ renderConversationSystemMessage(turnContext, message, width, node.absIdx);
679
+ } else if (message.role === 'tool') {
680
+ renderConversationToolMessage(turnContext, message, width, node.absIdx, node);
681
+ }
632
682
  }
633
- if (!isFoldedGroupMember(membership, groupedContext.collapseState)) {
634
- groupedContext.history.addLine(createEmptyLine(width));
683
+
684
+ const rendered = turnContext.history.getLineCount() > before;
685
+ if (!rendered) continue;
686
+ // Branch rows sit tight under their parent; the blank separator lands only
687
+ // after the last row of a top-level unit, which is what keeps a turn's
688
+ // whole subtree reading as one block instead of a run of spaced-out rows.
689
+ const next = plan[i + 1];
690
+ if (!next || next.depth === 0) {
691
+ turnContext.history.addLine(createEmptyLine(width));
635
692
  }
636
693
  }
637
694
  }