@pellux/goodvibes-tui 1.21.1 → 1.23.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 (38) hide show
  1. package/CHANGELOG.md +21 -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 +2 -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 +5 -3
  29. package/src/renderer/settings-modal.ts +8 -0
  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/runtime/services.ts +2 -2
  36. package/src/verification/verification-ledger.ts +115 -2
  37. package/src/version.ts +1 -1
  38. package/src/core/conversation-tool-groups.ts +0 -152
@@ -11,7 +11,8 @@
11
11
  "status.request",
12
12
  "location.request",
13
13
  "session.message",
14
- "automation.run"
14
+ "automation.run",
15
+ "device.capability"
15
16
  ],
16
17
  "scopes": [
17
18
  "remote:heartbeat",
@@ -823,7 +824,8 @@
823
824
  "status.request",
824
825
  "location.request",
825
826
  "session.message",
826
- "automation.run"
827
+ "automation.run",
828
+ "device.capability"
827
829
  ]
828
830
  },
829
831
  "command": {
@@ -1179,7 +1181,8 @@
1179
1181
  "status.request",
1180
1182
  "location.request",
1181
1183
  "session.message",
1182
- "automation.run"
1184
+ "automation.run",
1185
+ "device.capability"
1183
1186
  ]
1184
1187
  },
1185
1188
  "command": {
@@ -2030,7 +2033,8 @@
2030
2033
  "status.request",
2031
2034
  "location.request",
2032
2035
  "session.message",
2033
- "automation.run"
2036
+ "automation.run",
2037
+ "device.capability"
2034
2038
  ]
2035
2039
  },
2036
2040
  "command": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.21.1",
3
+ "version": "1.23.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -41,6 +41,8 @@
41
41
  "smoke:tui": "goodvibes-post-build-smoke",
42
42
  "build:windows": "bun build src/main.ts --compile --target=bun-windows-x64 --outfile dist/goodvibes-windows.exe",
43
43
  "build:all-shell": "bun run build:linux-x64 && bun run build:linux-arm64 && bun run build:macos-x64 && bun run build:macos-arm64 && bun run build:windows",
44
+ "typecheck": "bunx tsc --noEmit",
45
+ "typecheck:test": "bunx tsc --noEmit -p tsconfig.test.json",
44
46
  "test": "bun run scripts/run-tests.ts",
45
47
  "test:coverage": "bun run scripts/coverage-gate.ts",
46
48
  "version": "bun run scripts/prebuild.ts",
@@ -104,8 +106,8 @@
104
106
  "@anthropic-ai/vertex-sdk": "^0.16.0",
105
107
  "@ast-grep/napi": "^0.42.0",
106
108
  "@aws/bedrock-token-generator": "^1.1.0",
107
- "@pellux/goodvibes-sdk": "1.14.0",
108
- "@pellux/goodvibes-terminal-shell": "1.14.0",
109
+ "@pellux/goodvibes-sdk": "1.16.1",
110
+ "@pellux/goodvibes-terminal-shell": "1.16.1",
109
111
  "bash-language-server": "^5.6.0",
110
112
  "fuse.js": "^7.1.0",
111
113
  "graphql": "^16.13.2",
@@ -131,10 +133,10 @@
131
133
  "typescript": "^5.9.3"
132
134
  },
133
135
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.21.1",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.21.1",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.21.1",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.21.1"
136
+ "@pellux/goodvibes-tui-linux-x64": "1.23.0",
137
+ "@pellux/goodvibes-tui-linux-arm64": "1.23.0",
138
+ "@pellux/goodvibes-tui-darwin-x64": "1.23.0",
139
+ "@pellux/goodvibes-tui-darwin-arm64": "1.23.0"
138
140
  },
139
141
  "trustedDependencies": [],
140
142
  "overrides": {
@@ -31,6 +31,8 @@ import type { CliWorkspaceStatus, CliSandboxStatus, CliRelayStatus } from './sta
31
31
  import { detectSandboxAvailability, probeSandboxHost } from '@pellux/goodvibes-sdk/platform/tools/exec/sandbox';
32
32
  import { createFeatureFlagManager, deriveFeatureStates } from '@/runtime/index.ts';
33
33
  import { ensureGoodvibesGitignore } from './ensure-goodvibes-gitignore.ts';
34
+ import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
35
+ import { describeDaemonConfigMigration } from '@pellux/goodvibes-sdk/platform/config';
34
36
 
35
37
  type ShellEntrypointOwnership = {
36
38
  readonly workingDirectory: string;
@@ -109,6 +111,10 @@ export async function prepareShellCliRuntime(
109
111
  if (ensureGoodvibesGitignore(bootstrapWorkingDir)) {
110
112
  console.log("[goodvibes] added '.goodvibes/' to .gitignore — this directory holds transient TUI state (logs, session cache, exec output), not project source.");
111
113
  }
114
+ const daemonConfigMigration = runDaemonConfigMigration(bootstrapHomeDirectory);
115
+ if (daemonConfigMigration?.migrated && (daemonConfigMigration.marker.moved.length + daemonConfigMigration.marker.discarded.length) > 0) {
116
+ console.log(`[goodvibes] ${describeDaemonConfigMigration(daemonConfigMigration.marker)}`);
117
+ }
112
118
  const configManager = new ConfigManager({
113
119
  workingDir: bootstrapWorkingDir,
114
120
  homeDir: bootstrapHomeDirectory,
@@ -361,8 +361,12 @@ export function createPlatformServiceManager(runtime: CliServiceRuntime): Platfo
361
361
  command: daemonExecutable.command,
362
362
  args: [],
363
363
  env: {
364
- // The daemon CLI treats this as the GoodVibes home root, not the daemon state directory.
365
- GOODVIBES_DAEMON_HOME: runtime.homeDirectory,
364
+ // Two separate directories, deliberately: GOODVIBES_HOME is the GoodVibes
365
+ // tree root, GOODVIBES_DAEMON_HOME is the daemon's own identity home under
366
+ // it. They used to be the same variable, which meant pointing the daemon's
367
+ // home somewhere else silently moved the whole tree with it.
368
+ GOODVIBES_HOME: runtime.homeDirectory,
369
+ GOODVIBES_DAEMON_HOME: daemonHomeDir,
366
370
  GOODVIBES_DAEMON_TOKEN: process.env.GOODVIBES_DAEMON_TOKEN ?? '',
367
371
  GOODVIBES_HTTP_TOKEN: process.env.GOODVIBES_HTTP_TOKEN ?? '',
368
372
  NODE_ENV: process.env.NODE_ENV ?? 'production',
@@ -0,0 +1,46 @@
1
+ /**
2
+ * run-daemon-config-migration.ts — the one call every TUI composition root
3
+ * makes before constructing its `ConfigManager`.
4
+ *
5
+ * Daemon-owned configuration now has exactly one home:
6
+ * `~/.goodvibes/daemon/settings.json`. Before this migration, every product
7
+ * wrote every key (including daemon-only ones like `surfaces.telegram.*`)
8
+ * into its own per-surface silo, and the daemon only ever read
9
+ * `~/.goodvibes/tui/settings.json` — so a value written by, say, the agent
10
+ * surface reported a successful save and configured nothing the daemon could
11
+ * see.
12
+ *
13
+ * `migrateDaemonOwnedConfig` (SDK, `platform/config`) is idempotent and cheap
14
+ * on the fast path (one file read + JSON parse), so it is safe and correct to
15
+ * call this at every composition root that is about to construct a
16
+ * `ConfigManager` — not just the first one to run in a given process. It
17
+ * must never abort startup: any failure is caught, logged with the marker
18
+ * path so the failure is diagnosable, and startup continues on whatever
19
+ * config state already exists.
20
+ */
21
+ import {
22
+ migrateDaemonOwnedConfig,
23
+ daemonConfigPath,
24
+ daemonConfigMovedPath,
25
+ type DaemonConfigMigrationResult,
26
+ } from '@pellux/goodvibes-sdk/platform/config';
27
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
28
+
29
+ /**
30
+ * Run the daemon-owned-config migration for `homeDir`, tolerating any
31
+ * failure. Returns the migration result on success, or `null` when the
32
+ * migration itself threw (logged as a warning naming the marker path;
33
+ * startup proceeds either way).
34
+ */
35
+ export function runDaemonConfigMigration(homeDir: string): DaemonConfigMigrationResult | null {
36
+ try {
37
+ return migrateDaemonOwnedConfig({ homeDir, primarySurface: 'tui' });
38
+ } catch (error) {
39
+ const markerPath = daemonConfigMovedPath(daemonConfigPath(homeDir));
40
+ logger.warn('daemon-owned config migration failed; continuing with existing config state', {
41
+ markerPath,
42
+ error: summarizeError(error),
43
+ });
44
+ return null;
45
+ }
46
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * conversation-compaction-render.ts — folds a compaction-continuation user
3
+ * message (the instruction block the compactor re-injects after an automatic
4
+ * compaction) to one header + preview line.
5
+ *
6
+ * Extracted from conversation-rendering.ts to keep that file under the
7
+ * architecture line-count gate; behaviour is unchanged.
8
+ */
9
+
10
+ import { activeTheme } from '../renderer/theme.ts';
11
+ import { renderMarkdownTracked } from '../renderer/markdown.ts';
12
+ import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
13
+ import { GLYPHS } from '../renderer/ui-primitives.ts';
14
+ import type { Line } from '../types/grid.ts';
15
+ import type { BlockMeta } from './conversation-types.ts';
16
+
17
+ /**
18
+ * The slice of the render context this module needs, declared structurally
19
+ * rather than imported from conversation-rendering.ts — that import would form
20
+ * a 2-file cycle the architecture check rejects, and this module only ever
21
+ * touches these three fields.
22
+ */
23
+ export interface CompactionRenderContext {
24
+ readonly history: {
25
+ addLine: (line: Line) => void;
26
+ addLines: (lines: Line[]) => void;
27
+ getLineCount: () => number;
28
+ };
29
+ readonly blockRegistry: BlockMeta[];
30
+ readonly collapseState: Map<string, boolean>;
31
+ }
32
+
33
+ /** Fold a compaction-continuation user message to one header + preview line. */
34
+ export function renderCompactionContinuationMessage(
35
+ context: CompactionRenderContext,
36
+ content: string,
37
+ width: number,
38
+ msgIdx: number,
39
+ ): void {
40
+ const T = activeTheme();
41
+ const collapseKey = `msg_${msgIdx}`;
42
+ const blockIdx = context.blockRegistry.length;
43
+ const startLine = context.history.getLineCount();
44
+ const lineCount = content.split('\n').length;
45
+ const isCollapsed = context.collapseState.has(collapseKey)
46
+ ? context.collapseState.get(collapseKey)!
47
+ : true;
48
+ if (!context.collapseState.has(collapseKey)) {
49
+ context.collapseState.set(collapseKey, true);
50
+ }
51
+
52
+ context.history.addLine(renderConversationEventLine(width, {
53
+ marker: GLYPHS.status.active,
54
+ markerFg: T.toolAccent,
55
+ label: 'compaction handoff',
56
+ labelFg: T.toolAccent,
57
+ detailFg: '244',
58
+ }, [
59
+ { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
60
+ ]));
61
+
62
+ if (isCollapsed) {
63
+ const rendered = renderConversationCollapsedFragment(
64
+ 'compacted-context handoff (re-injected instructions + session summary)',
65
+ width,
66
+ {
67
+ prefix: ` ${GLYPHS.navigation.collapsed} `,
68
+ prefixFg: T.toolAccent,
69
+ text: '244',
70
+ bodyBg: T.collapsedBodyBg,
71
+ dim: true,
72
+ },
73
+ );
74
+ context.history.addLines(rendered);
75
+ } else {
76
+ context.history.addLines(renderMarkdownTracked(content, width).lines);
77
+ }
78
+
79
+ context.blockRegistry.push({
80
+ blockIndex: blockIdx,
81
+ collapseKey,
82
+ type: 'tool',
83
+ startLine,
84
+ lineCount: context.history.getLineCount() - startLine,
85
+ rawContent: content,
86
+ });
87
+ }
88
+
@@ -51,12 +51,18 @@ import type { ConversationRenderContext } from './conversation-rendering.ts';
51
51
  import {
52
52
  renderConversationAssistantMessage,
53
53
  renderConversationSystemMessage,
54
+ renderConversationToolCallNode,
54
55
  renderConversationToolMessage,
55
56
  renderConversationUserMessage,
56
57
  collectCompletedToolCallIds,
57
- isFoldedGroupMember,
58
+ isTurnCollapsed,
58
59
  } from './conversation-rendering.ts';
59
- import { computeToolGroupMembership, type ToolGroupMembership } from './conversation-tool-groups.ts';
60
+ import {
61
+ buildRenderPlan,
62
+ computeAssistantTurns,
63
+ type AssistantTurnMembership,
64
+ type RenderNode,
65
+ } from './conversation-turn-structure.ts';
60
66
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
61
67
  // SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
62
68
  // conversation-rendering.ts's own import.
@@ -112,17 +118,27 @@ interface KeyMeta {
112
118
  */
113
119
  readonly pendingToolKey: string | undefined;
114
120
  /**
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
+ * Turn membership and tree-structure fields, flattened so a change
122
+ * invalidates the entry like any other structural input.
123
+ *
124
+ * This is what makes connector glyphs safe to recompute rather than cache:
125
+ * when a sibling arrives, the previously-last row's `connector` flips
126
+ * `└`→`├`, that value differs from the cached one, and exactly that row
127
+ * re-renders. Nothing else in the row changes, so the compositor repaints a
128
+ * single connector cell.
121
129
  */
122
- readonly groupKey: string | undefined;
123
- readonly groupIsFirst: boolean;
124
- readonly groupToolCount: number;
125
- readonly groupTotalLines: number;
130
+ readonly turnKey: string | undefined;
131
+ readonly turnIsHead: boolean;
132
+ readonly turnToolCount: number;
133
+ readonly turnSharedLabel: string | undefined;
134
+ readonly turnHasReasoning: boolean;
135
+ /** Row depth and connector — both change when structure around a row changes. */
136
+ readonly depth: number;
137
+ readonly connector: string | undefined;
138
+ /** Ancestor gutter pattern, flattened; a change repaints the row's gutters. */
139
+ readonly openAncestors: string;
140
+ /** Whether this row is followed by the blank separator. */
141
+ readonly trailingBlank: boolean;
126
142
  }
127
143
 
128
144
  /**
@@ -246,22 +262,26 @@ function makeRecordingCollapseState(
246
262
  });
247
263
  }
248
264
 
249
- /** Dispatch a single message to its render function. */
265
+ /** Dispatch a single planned row to its render function. */
250
266
  function renderOne(
251
267
  ctx: ConversationRenderContext,
252
- message: Message,
268
+ node: RenderNode,
253
269
  width: number,
254
- absoluteIdx: number,
255
270
  cfg: RenderConfig,
256
271
  ): void {
272
+ if (node.kind === 'toolcall') {
273
+ renderConversationToolCallNode(ctx, node, width);
274
+ return;
275
+ }
276
+ const message = node.message;
257
277
  if (message.role === 'user') {
258
- renderConversationUserMessage(ctx, message, width, absoluteIdx);
278
+ renderConversationUserMessage(ctx, message, width, node.absIdx);
259
279
  } else if (message.role === 'assistant') {
260
- renderConversationAssistantMessage(ctx, message, width, cfg.lineNumberMode, cfg.collapseThreshold, absoluteIdx);
280
+ renderConversationAssistantMessage(ctx, message, width, cfg.lineNumberMode, cfg.collapseThreshold, node.absIdx);
261
281
  } else if (message.role === 'system') {
262
- renderConversationSystemMessage(ctx, message, width, absoluteIdx);
282
+ renderConversationSystemMessage(ctx, message, width, node.absIdx);
263
283
  } else if (message.role === 'tool') {
264
- renderConversationToolMessage(ctx, message, width, absoluteIdx);
284
+ renderConversationToolMessage(ctx, message, width, node.absIdx, node);
265
285
  }
266
286
  }
267
287
 
@@ -274,7 +294,7 @@ function renderOne(
274
294
  * own beyond speed and reduced allocation churn.
275
295
  */
276
296
  export class MessageLineCache {
277
- private entries: Map<number, CacheEntry> = new Map();
297
+ private entries: Map<string, CacheEntry> = new Map();
278
298
 
279
299
  /** Drop all cached entries (wholesale message replacement / reset). */
280
300
  public clear(): void {
@@ -319,61 +339,62 @@ export class MessageLineCache {
319
339
  // Tool calls with no matching tool-result message are still pending; the
320
340
  // render context and the cache key both depend on this. (item 2c.)
321
341
  const completedToolCallIds = collectCompletedToolCallIds(messages);
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 };
328
-
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>();
334
-
335
- for (let i = 0; i < messages.length; i++) {
336
- const message = messages[i]!;
337
- const absoluteIdx = msgIndexOffset + i;
342
+ const assistantTurns = computeAssistantTurns(messages, msgIndexOffset);
343
+ // Structural plan: a permutation of the slice (results lifted under their
344
+ // calls, spawned agents spliced under the call that spawned them), rebuilt
345
+ // every pass so connectors and nesting always reflect live structure.
346
+ const plan = buildRenderPlan(messages, msgIndexOffset, {
347
+ resolveAgentSnapshot: context.resolveAgentSnapshot,
348
+ });
349
+ const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, assistantTurns };
350
+
351
+ const touched = new Set<string>();
352
+ const turnHeaderLines = new Map<string, number>();
353
+
354
+ for (let i = 0; i < plan.length; i++) {
355
+ const node = plan[i]!;
338
356
  const base = context.history.getLineCount();
339
357
  const blockBase = context.blockRegistry.length;
358
+ const isRoot = node.scope === '';
359
+ const turn = assistantTurns.get(node.absIdx);
360
+
361
+ if (node.kind === 'message') {
362
+ if (turn?.isHead) turnHeaderLines.set(turn.turnKey, base);
363
+ // Nested rows index into their own agent's snapshot and must not write
364
+ // the root transcript's registry.
365
+ if (isRoot) {
366
+ messageLineRegistry[node.absIdx] = turn && isTurnCollapsed(turn, context.collapseState)
367
+ ? (turnHeaderLines.get(turn.turnKey) ?? base)
368
+ : base;
369
+ }
370
+ }
340
371
 
341
- const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
342
- const kind = context.messageKindRegistry.get(absoluteIdx);
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;
372
+ const next = plan[i + 1];
373
+ const trailingBlank = !next || next.depth === 0;
374
+ const uncacheable = isRoot && node.kind === 'message' && node.absIdx === streamingPlaceholderAbsIdx;
375
+ const kind = isRoot ? context.messageKindRegistry.get(node.absIdx) : undefined;
376
+ const pendingToolKey = pendingToolKeyOf(node.message, completedToolCallIds);
356
377
 
357
378
  if (!uncacheable) {
358
- const existing = this.entries.get(absoluteIdx);
359
- if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, pendingToolKey, groupMembership, context.collapseState)) {
379
+ const existing = this.entries.get(node.id);
380
+ if (existing && this.isValid(existing, node, width, cfg, blockBase, kind, pendingToolKey, turn, trailingBlank, context.collapseState)) {
360
381
  this.applyEntry(context, existing, base);
361
- touched.add(absoluteIdx);
382
+ touched.add(node.id);
362
383
  continue;
363
384
  }
364
385
  }
365
386
 
366
- const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, pendingToolKey, groupMembership);
387
+ const entry = this.renderScratch(renderContext, node, width, cfg, blockBase, kind, pendingToolKey, turn, trailingBlank);
367
388
  this.applyEntry(context, entry, base);
368
389
  if (!uncacheable) {
369
- this.entries.set(absoluteIdx, entry);
370
- touched.add(absoluteIdx);
390
+ this.entries.set(node.id, entry);
391
+ touched.add(node.id);
371
392
  }
372
393
  }
373
394
 
374
- // Mark-and-sweep: a full rebuild renders every currently-visible message, so
375
- // any entry not touched this pass is off-screen (or evicted by a shrink) and
376
- // is dropped to bound memory to the visible set.
395
+ // Mark-and-sweep: a full rebuild plans every currently-visible row, so any
396
+ // entry not touched this pass is gone (off-screen, or a row whose structure
397
+ // no longer exists) and is dropped to bound memory to the visible set.
377
398
  if (this.entries.size > touched.size) {
378
399
  for (const key of this.entries.keys()) {
379
400
  if (!touched.has(key)) this.entries.delete(key);
@@ -381,21 +402,22 @@ export class MessageLineCache {
381
402
  }
382
403
  }
383
404
 
384
- /** Validate a cached entry against the message's current complete inputs. */
405
+ /** Validate a cached entry against the row's current complete inputs. */
385
406
  private isValid(
386
407
  entry: CacheEntry,
387
- message: Message,
408
+ node: RenderNode,
388
409
  width: number,
389
410
  cfg: RenderConfig,
390
411
  blockBase: number,
391
412
  kind: SystemMessageKind | undefined,
392
413
  pendingToolKey: string | undefined,
393
- groupMembership: ToolGroupMembership | undefined,
414
+ turn: AssistantTurnMembership | undefined,
415
+ trailingBlank: boolean,
394
416
  collapseState: Map<string, boolean>,
395
417
  ): boolean {
396
418
  const k = entry.keyMeta;
397
419
  if (
398
- k.role !== message.role ||
420
+ k.role !== node.message.role ||
399
421
  k.width !== width ||
400
422
  k.lineNumberMode !== cfg.lineNumberMode ||
401
423
  k.collapseThreshold !== cfg.collapseThreshold ||
@@ -404,14 +426,19 @@ export class MessageLineCache {
404
426
  k.blockBase !== blockBase ||
405
427
  k.kind !== kind ||
406
428
  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)
429
+ k.turnKey !== turn?.turnKey ||
430
+ k.turnIsHead !== (turn?.isHead ?? false) ||
431
+ k.turnToolCount !== (turn?.toolCallCount ?? 0) ||
432
+ k.turnSharedLabel !== turn?.sharedToolLabel ||
433
+ k.turnHasReasoning !== (turn?.hasReasoning ?? false) ||
434
+ k.depth !== node.depth ||
435
+ k.connector !== node.connector ||
436
+ k.openAncestors !== node.openAncestorDepths.join(',') ||
437
+ k.trailingBlank !== trailingBlank
411
438
  ) {
412
439
  return false;
413
440
  }
414
- if (!contentUnchanged(entry.contentSig, message)) return false;
441
+ if (!contentUnchanged(entry.contentSig, node.message)) return false;
415
442
  for (const [key, value] of entry.collapseDeps) {
416
443
  if (collapseState.get(key) !== value) return false;
417
444
  }
@@ -419,21 +446,21 @@ export class MessageLineCache {
419
446
  }
420
447
 
421
448
  /**
422
- * Render a single message into an isolated scratch context, capturing its
423
- * lines, block metas (message-relative), error-line offsets (message-relative),
424
- * and the collapse-state reads it depends on. Collapse-default WRITES pass
449
+ * Render a single planned row into an isolated scratch context, capturing its
450
+ * lines, block metas (row-relative), error-line offsets (row-relative), and
451
+ * the collapse-state reads it depends on. Collapse-default WRITES pass
425
452
  * through to the real collapseState so persistent defaults match a cold render.
426
453
  */
427
454
  private renderScratch(
428
455
  context: ConversationRenderContext,
429
- message: Message,
456
+ node: RenderNode,
430
457
  width: number,
431
- absoluteIdx: number,
432
458
  cfg: RenderConfig,
433
459
  blockBase: number,
434
460
  kind: SystemMessageKind | undefined,
435
461
  pendingToolKey: string | undefined,
436
- groupMembership: ToolGroupMembership | undefined,
462
+ turn: AssistantTurnMembership | undefined,
463
+ trailingBlank: boolean,
437
464
  ): CacheEntry {
438
465
  const scratchLines: Line[] = [];
439
466
  const scratchHistory = {
@@ -441,9 +468,6 @@ export class MessageLineCache {
441
468
  addLines: (lines: Line[]): void => { for (const line of lines) scratchLines.push(line); },
442
469
  getLineCount: (): number => scratchLines.length,
443
470
  };
444
- // Pre-size to blockBase so blockRegistry.length (the global block index the
445
- // collapseKey embeds) matches the live registry; slice(blockBase) recovers
446
- // only this message's blocks afterwards.
447
471
  const scratchBlocks: BlockMeta[] = new Array(blockBase);
448
472
  const scratchErrors: number[] = [];
449
473
  const readKeys = new Set<string>();
@@ -458,16 +482,15 @@ export class MessageLineCache {
458
482
  configManager: context.configManager,
459
483
  splashOptions: context.splashOptions,
460
484
  completedToolCallIds: context.completedToolCallIds,
461
- toolGroupMembership: context.toolGroupMembership,
485
+ assistantTurns: context.assistantTurns,
486
+ resolveAgentSnapshot: context.resolveAgentSnapshot,
462
487
  };
463
488
 
464
- renderOne(scratchCtx, message, width, absoluteIdx, cfg);
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)) {
489
+ renderOne(scratchCtx, node, width, cfg);
490
+ // A row that rendered nothing (hidden by a collapsed turn) gets no
491
+ // separator either; otherwise the blank lands only after the last row of a
492
+ // top-level unit, so a turn's subtree reads as one block.
493
+ if (scratchLines.length > 0 && trailingBlank) {
471
494
  scratchLines.push(createEmptyLine(width));
472
495
  }
473
496
 
@@ -476,7 +499,7 @@ export class MessageLineCache {
476
499
 
477
500
  return {
478
501
  keyMeta: {
479
- role: message.role,
502
+ role: node.message.role,
480
503
  width,
481
504
  lineNumberMode: cfg.lineNumberMode,
482
505
  collapseThreshold: cfg.collapseThreshold,
@@ -485,12 +508,17 @@ export class MessageLineCache {
485
508
  blockBase,
486
509
  kind,
487
510
  pendingToolKey,
488
- groupKey: groupMembership?.groupKey,
489
- groupIsFirst: groupMembership?.isFirst ?? false,
490
- groupToolCount: groupMembership?.toolCount ?? 0,
491
- groupTotalLines: groupMembership?.totalLines ?? 0,
511
+ turnKey: turn?.turnKey,
512
+ turnIsHead: turn?.isHead ?? false,
513
+ turnToolCount: turn?.toolCallCount ?? 0,
514
+ turnSharedLabel: turn?.sharedToolLabel,
515
+ turnHasReasoning: turn?.hasReasoning ?? false,
516
+ depth: node.depth,
517
+ connector: node.connector,
518
+ openAncestors: node.openAncestorDepths.join(','),
519
+ trailingBlank,
492
520
  },
493
- contentSig: contentSigOf(message),
521
+ contentSig: contentSigOf(node.message),
494
522
  lines: scratchLines,
495
523
  blocks: scratchBlocks.slice(blockBase),
496
524
  errorRelLines: scratchErrors,