@pellux/goodvibes-tui 1.19.6 → 1.20.1

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 (94) hide show
  1. package/CHANGELOG.md +13 -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/bundle-command.ts +1 -1
  8. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  9. package/src/cli/entrypoint.ts +5 -1
  10. package/src/cli/launch-auto-update.ts +63 -4
  11. package/src/cli/management.ts +1 -1
  12. package/src/cli/tui-startup.ts +131 -15
  13. package/src/config/surface.ts +21 -0
  14. package/src/config/tui-extension-settings.ts +9 -1
  15. package/src/core/active-model-identity.ts +153 -0
  16. package/src/core/conversation-line-cache.ts +1 -1
  17. package/src/core/conversation-rendering.ts +70 -24
  18. package/src/core/conversation-tool-groups.ts +23 -10
  19. package/src/core/conversation-types.ts +9 -0
  20. package/src/core/conversation-user-receipts.ts +42 -0
  21. package/src/core/conversation.ts +20 -17
  22. package/src/core/rewind-turn-anchors.ts +12 -13
  23. package/src/core/session-recovery.ts +59 -2
  24. package/src/core/session-resume-core.ts +206 -0
  25. package/src/core/stream-event-wiring.ts +174 -14
  26. package/src/core/system-message-router.ts +22 -0
  27. package/src/core/transcript-journal.ts +32 -6
  28. package/src/core/turn-event-wiring.ts +55 -12
  29. package/src/input/command-args-hint.ts +11 -2
  30. package/src/input/command-registry.ts +19 -0
  31. package/src/input/commands/checkpoint-runtime.ts +3 -3
  32. package/src/input/commands/health-runtime.ts +1 -3
  33. package/src/input/commands/local-runtime.ts +1 -1
  34. package/src/input/commands/rewind-runtime.ts +132 -1
  35. package/src/input/commands/runtime-services.ts +11 -1
  36. package/src/input/commands/secret-runtime.ts +1 -1
  37. package/src/input/commands/session-content.ts +9 -1
  38. package/src/input/commands/session-picker-filter.ts +28 -0
  39. package/src/input/commands/session-workflow.ts +62 -73
  40. package/src/input/commands/session.ts +36 -1
  41. package/src/input/commands/shell-core.ts +57 -5
  42. package/src/input/commands/update-runtime.ts +120 -4
  43. package/src/input/commands/websearch-runtime.ts +1 -1
  44. package/src/input/handler-content-actions.ts +63 -39
  45. package/src/input/handler-feed-routes.ts +17 -3
  46. package/src/input/handler-feed.ts +3 -0
  47. package/src/input/handler-interactions.ts +19 -23
  48. package/src/input/handler-modal-token-routes.ts +5 -0
  49. package/src/input/handler-types.ts +4 -1
  50. package/src/input/handler-ui-state.ts +6 -2
  51. package/src/input/handler.ts +19 -9
  52. package/src/input/search.ts +94 -2
  53. package/src/input/selection-modal-queue.ts +176 -0
  54. package/src/main.ts +66 -61
  55. package/src/panels/builtin/operations.ts +2 -1
  56. package/src/panels/provider-health-domains.ts +2 -2
  57. package/src/{cli → providers}/provider-classification.ts +12 -0
  58. package/src/renderer/block-actions-overlay.ts +59 -0
  59. package/src/renderer/block-actions.ts +1 -3
  60. package/src/renderer/context-status-hint.ts +73 -5
  61. package/src/renderer/conversation-layout.ts +16 -0
  62. package/src/renderer/conversation-overlays.ts +6 -0
  63. package/src/renderer/header-line.ts +146 -0
  64. package/src/renderer/help-overlay.ts +69 -53
  65. package/src/renderer/selection-modal-overlay.ts +66 -10
  66. package/src/renderer/shell-surface.ts +51 -0
  67. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  68. package/src/renderer/ui-factory.ts +26 -79
  69. package/src/runtime/bootstrap-command-context.ts +5 -1
  70. package/src/runtime/bootstrap-command-parts.ts +5 -2
  71. package/src/runtime/bootstrap-core.ts +3 -3
  72. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  73. package/src/runtime/bootstrap-shell.ts +31 -16
  74. package/src/runtime/bootstrap.ts +13 -18
  75. package/src/runtime/code-index-services.ts +19 -0
  76. package/src/runtime/durability-services.ts +11 -7
  77. package/src/runtime/index.ts +12 -0
  78. package/src/runtime/process-lifecycle.ts +63 -2
  79. package/src/runtime/recovery-autosave.ts +48 -0
  80. package/src/runtime/recovery-decisions.ts +126 -0
  81. package/src/runtime/recovery-offer-wiring.ts +89 -0
  82. package/src/runtime/recovery-prompt.ts +362 -0
  83. package/src/runtime/resume-notice.ts +28 -93
  84. package/src/runtime/services.ts +14 -16
  85. package/src/runtime/session-liveness-marker.ts +120 -0
  86. package/src/runtime/session-pointer-surface.ts +22 -0
  87. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  88. package/src/runtime/session-storage-services.ts +41 -0
  89. package/src/runtime/workspace-checkpointing.ts +10 -2
  90. package/src/shell/recovery-input-helpers.ts +7 -3
  91. package/src/shell/retry-affordance.ts +123 -0
  92. package/src/shell/ui-openers.ts +9 -0
  93. package/src/utils/splash-lines.ts +1 -1
  94. package/src/version.ts +1 -1
@@ -323,7 +323,7 @@ export class MessageLineCache {
323
323
  // turn under a single collapsible header (see conversation-tool-groups.ts).
324
324
  // Computed once per pass, over the same slice+offset appendConversationMessages
325
325
  // would see, so both entry points fold identically.
326
- const toolGroupMembership = computeToolGroupMembership(messages, msgIndexOffset);
326
+ const toolGroupMembership = computeToolGroupMembership(messages, msgIndexOffset, width);
327
327
  const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, toolGroupMembership };
328
328
 
329
329
  const touched = new Set<number>();
@@ -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';
@@ -207,8 +207,34 @@ export function renderConversationAssistantMessage(
207
207
  const thinkingStartLine = context.history.getLineCount();
208
208
  const thinkingBlockIdx = context.blockRegistry.length;
209
209
  const thinkingCollapseKey = `msg_${msgIdx}_thinking`;
210
- const thinkingLines = renderThinkingBlock(message.reasoningContent, width);
211
- 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
+ }
212
238
  context.history.addLine(createEmptyLine(width));
213
239
  const thinkingRenderedLines = context.history.getLineCount() - thinkingStartLine;
214
240
  context.blockRegistry.push({
@@ -340,6 +366,30 @@ export function isFoldedGroupMember(
340
366
  return membership !== undefined && !membership.isFirst && (collapseState.get(membership.groupKey) ?? true);
341
367
  }
342
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
+
343
393
  /**
344
394
  * Render the synthetic header line for a folded run of >=2 tool-result
345
395
  * messages: one line + one BlockMeta (type 'tool_group'), shared by every
@@ -363,6 +413,7 @@ function renderToolGroupHeader(
363
413
 
364
414
  const blockIdx = context.blockRegistry.length;
365
415
  const startLine = context.history.getLineCount();
416
+ const toolNamesSummary = summarizeToolNames(membership.toolNames);
366
417
  context.history.addLine(renderConversationEventLine(width, {
367
418
  marker: GLYPHS.surface.altCursor,
368
419
  markerFg: T.toolAccent,
@@ -371,6 +422,7 @@ function renderToolGroupHeader(
371
422
  detailFg: '244',
372
423
  }, [
373
424
  { text: ` ${membership.toolCount} tool${membership.toolCount === 1 ? '' : 's'} `, fg: T.toolAccent },
425
+ ...(toolNamesSummary ? [{ text: ` ${toolNamesSummary} `, fg: T.toolNameFg }] : []),
374
426
  { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} `, fg: '244', dim: true },
375
427
  ]));
376
428
 
@@ -380,7 +432,7 @@ function renderToolGroupHeader(
380
432
  type: 'tool_group',
381
433
  startLine,
382
434
  lineCount: 1,
383
- rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
435
+ rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${toolNamesSummary}, ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
384
436
  groupMemberIndexes: membership.memberIndexes,
385
437
  });
386
438
 
@@ -409,14 +461,18 @@ export function renderConversationToolMessage(
409
461
  const blockIdx = context.blockRegistry.length;
410
462
  const startLine = context.history.getLineCount();
411
463
  const contentLines = message.content.split('\n');
412
- const lineCount = contentLines.length;
413
- const hasDiffHeader = contentLines.some((l) => l.startsWith('--- ')) && contentLines.some((l) => l.startsWith('+++ '));
414
- const hasHunk = contentLines.some((l) => l.startsWith('@@ '));
415
- const isDiff = hasDiffHeader && hasHunk;
464
+ const isDiff = isDiffContent(message.content);
416
465
  const blockType: 'diff' | 'tool' = isDiff ? 'diff' : 'tool';
417
466
  // Parsed once, ahead of the collapse check, so it's available for the
418
467
  // block-registry meta merge below regardless of collapsed/expanded state.
419
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;
420
476
 
421
477
  // Human one-line summary for tool results (write/read/exec/edit): shown as the
422
478
  // collapsed line so the transcript reads "wrote foo.txt (532 B)" instead of a
@@ -485,22 +541,11 @@ export function renderConversationToolMessage(
485
541
  dim: true,
486
542
  });
487
543
  context.history.addLines(rendered);
488
- } else if (isDiff) {
489
- // No filename banner: the diff text's own --- / +++ headers already
490
- // identify the file (matches the pre-v0.9.6 call site in tool-call.ts).
491
- context.history.addLines(renderDiffView(message.content, width));
492
544
  } else {
493
- let contentToRender = message.content;
494
- const trimmed = contentToRender.trimStart();
495
- if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && contentToRender.length < 100_000) {
496
- try {
497
- const parsed = JSON.parse(contentToRender);
498
- contentToRender = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
499
- } catch {
500
- // Leave invalid JSON as-is.
501
- }
502
- }
503
- 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);
504
549
  }
505
550
 
506
551
  const renderedLineCount = context.history.getLineCount() - startLine;
@@ -511,6 +556,7 @@ export function renderConversationToolMessage(
511
556
  startLine,
512
557
  lineCount: renderedLineCount,
513
558
  rawContent: message.content,
559
+ toolName: message.toolName,
514
560
  };
515
561
 
516
562
  if (isDiff && diffParse) {
@@ -545,7 +591,7 @@ export function appendConversationMessages(
545
591
  // unless the caller already supplied membership (the line-cache path
546
592
  // computes it once per renderInto call and threads it through).
547
593
  const toolGroupMembership = renderContext.toolGroupMembership
548
- ?? computeToolGroupMembership(messages, msgIndexOffset);
594
+ ?? computeToolGroupMembership(messages, msgIndexOffset, width);
549
595
  const groupedContext: ConversationRenderContext = renderContext.toolGroupMembership !== undefined
550
596
  ? renderContext
551
597
  : { ...renderContext, toolGroupMembership };
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
24
+ import { renderExpandedToolResultLines } from '../renderer/tool-result-expanded-lines.ts';
24
25
 
25
26
  type Message = ConversationMessageSnapshot;
26
27
 
@@ -44,10 +45,15 @@ export interface ToolGroupMembership {
44
45
  readonly isFirst: boolean;
45
46
  /** Matched tool-result messages in the group (the header's "N tools"). */
46
47
  readonly toolCount: number;
47
- /** Sum of each member's raw content line count (the header's "N lines") —
48
- * the same count each member already shows on its own "N lines" badge, so
49
- * the group total is honest by construction, not an estimate. */
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). */
50
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[];
51
57
  /**
52
58
  * Absolute indexes of every tool-result message in the group, in order —
53
59
  * the same list for every member. Carried onto the group's BlockMeta so a
@@ -58,10 +64,6 @@ export interface ToolGroupMembership {
58
64
  readonly memberIndexes: readonly number[];
59
65
  }
60
66
 
61
- function rawLineCount(content: string): number {
62
- return content.split('\n').length;
63
- }
64
-
65
67
  /**
66
68
  * Scan `messages` for assistant-message runs of >=2 consecutive matching
67
69
  * tool-result messages. A run breaks on the first message that isn't a
@@ -103,19 +105,28 @@ export function detectToolGroups(
103
105
  return groups;
104
106
  }
105
107
 
106
- /** Build the per-tool-message membership lookup used by the renderer/cache. */
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. */
107
113
  export function buildToolGroupMembership(
108
114
  groups: readonly ToolGroup[],
109
115
  messages: readonly Message[],
110
116
  indexOffset: number,
117
+ width: number,
111
118
  ): ReadonlyMap<number, ToolGroupMembership> {
112
119
  const membership = new Map<number, ToolGroupMembership>();
113
120
  for (const group of groups) {
114
121
  const groupKey = `group_${group.assistantIdx}`;
115
122
  let totalLines = 0;
123
+ const toolNames: string[] = [];
116
124
  for (const absIdx of group.toolMessageIndexes) {
117
125
  const message = messages[absIdx - indexOffset];
118
- if (message && message.role === 'tool') totalLines += rawLineCount(message.content);
126
+ if (message && message.role === 'tool') {
127
+ totalLines += renderExpandedToolResultLines(message.content, width).length;
128
+ toolNames.push(message.toolName ?? 'tool');
129
+ }
119
130
  }
120
131
  group.toolMessageIndexes.forEach((absIdx, idx) => {
121
132
  membership.set(absIdx, {
@@ -123,6 +134,7 @@ export function buildToolGroupMembership(
123
134
  isFirst: idx === 0,
124
135
  toolCount: group.toolMessageIndexes.length,
125
136
  totalLines,
137
+ toolNames,
126
138
  memberIndexes: group.toolMessageIndexes,
127
139
  });
128
140
  });
@@ -134,6 +146,7 @@ export function buildToolGroupMembership(
134
146
  export function computeToolGroupMembership(
135
147
  messages: readonly Message[],
136
148
  indexOffset = 0,
149
+ width = 80,
137
150
  ): ReadonlyMap<number, ToolGroupMembership> {
138
- return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset);
151
+ return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset, width);
139
152
  }
@@ -39,4 +39,13 @@ export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
39
39
  * individually while the group stays collapsed.
40
40
  */
41
41
  groupMemberIndexes?: readonly number[];
42
+ /**
43
+ * The tool call's name, when this block renders a 'tool' result (or is the
44
+ * header of a 'tool_group' whose members share a run — the header carries
45
+ * the first member's name). Used to name the block honestly in action
46
+ * receipts (e.g. "tool result: exec") — see describeBlockForReceipt in
47
+ * handler-content-actions.ts. Undefined for non-tool block types and for
48
+ * standalone tool results with no recorded name.
49
+ */
50
+ toolName?: string;
42
51
  };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * conversation-user-receipts.ts — tracks which system-role message indices
3
+ * are a direct receipt for an explicit user action (e.g. answering the
4
+ * startup recovery modal's Resume/Keep/Remove choice), as opposed to ambient
5
+ * boot chatter (provider registration, the resume notice, etc.). Kept in its
6
+ * own file so ConversationManager (core/conversation.ts) — already near its
7
+ * line-count gate — doesn't have to carry the bookkeeping inline.
8
+ *
9
+ * ConversationManager.rebuildHistory()'s splash-vs-transcript check consults
10
+ * this via ConversationManager.addUserActionReceipt(): a receipt the user is
11
+ * actively waiting on must displace the splash the way a user message does,
12
+ * while quiet startup plumbing keeps rendering underneath it. Indices mirror
13
+ * messageKindRegistry's own lifecycle — cleared on resetAll(), purged for
14
+ * indices an undo() frees for reuse, deleted when a later add overwrites the
15
+ * same recycled index with a non-receipt message.
16
+ */
17
+ export class UserReceiptIndices {
18
+ private readonly indices = new Set<number>();
19
+
20
+ add(index: number): void {
21
+ this.indices.add(index);
22
+ }
23
+
24
+ delete(index: number): void {
25
+ this.indices.delete(index);
26
+ }
27
+
28
+ has(index: number): boolean {
29
+ return this.indices.has(index);
30
+ }
31
+
32
+ clear(): void {
33
+ this.indices.clear();
34
+ }
35
+
36
+ /** Drop every tracked index >= minIndex — mirrors undo()'s tail-splice. */
37
+ purgeFrom(minIndex: number): void {
38
+ for (const key of this.indices) {
39
+ if (key >= minIndex) this.indices.delete(key);
40
+ }
41
+ }
42
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from '@pellux/goodvibes-sdk/platform/core';
15
15
  import type { BlockMeta } from './conversation-types.ts';
16
16
  import { MessageLineCache } from './conversation-line-cache.ts';
17
+ import { UserReceiptIndices } from './conversation-user-receipts.ts';
17
18
  import {
18
19
  addConversationSplashScreen,
19
20
  conversationTextToLines,
@@ -139,6 +140,8 @@ export class ConversationManager extends SdkConversationManager {
139
140
  private errorLineRegistry: number[] = [];
140
141
  /** Maps message index → SystemMessageKind for system-role messages. */
141
142
  private messageKindRegistry: Map<number, SystemMessageKind> = new Map();
143
+ /** See conversation-user-receipts.ts. */
144
+ private userReceiptIndices = new UserReceiptIndices();
142
145
  /** Streaming block start line in history buffer (for incremental streaming update). */
143
146
  private streamingStartLine = -1;
144
147
  /**
@@ -221,25 +224,21 @@ export class ConversationManager extends SdkConversationManager {
221
224
  // stale kind (e.g. 'operational') and silently mis-classify the new message.
222
225
  const nextIndex = this.getMessageSnapshot().length;
223
226
  this.messageKindRegistry.delete(nextIndex);
227
+ this.userReceiptIndices.delete(nextIndex);
224
228
  super.addSystemMessage(content);
225
229
  this.markDirty();
226
230
  }
227
231
 
228
232
  /**
229
- * addTypedSystemMessage - Add a system message with an explicit kind tag.
230
- * The kind is stored in messageKindRegistry (keyed by the message index that
231
- * will be assigned after the push) so that renderConversationSystemMessage
232
- * can use it instead of text-based pattern matching.
233
- *
234
- * Called by SystemMessageRouter.routeTypedSystemMessage when routing to the
235
- * conversation surface. Falls back to addSystemMessage for callers that do
236
- * not have kind information.
233
+ * addTypedSystemMessage - System message with an explicit kind tag, stored
234
+ * in messageKindRegistry. `isUserReceipt` (conversation-user-receipts.ts)
235
+ * additionally marks it for rebuildHistory()'s splash check — only for a
236
+ * direct receipt to an explicit user action, never ambient boot chatter.
237
237
  */
238
- public addTypedSystemMessage(content: string, kind: SystemMessageKind): void {
239
- // getMessageSnapshot().length is the index this message will receive after
240
- // addSystemMessage appends it to the messages array.
238
+ public addTypedSystemMessage(content: string, kind: SystemMessageKind, opts?: { isUserReceipt?: boolean }): void {
241
239
  const nextIndex = this.getMessageSnapshot().length;
242
240
  this.messageKindRegistry.set(nextIndex, kind);
241
+ if (opts?.isUserReceipt) this.userReceiptIndices.add(nextIndex); else this.userReceiptIndices.delete(nextIndex);
243
242
  super.addSystemMessage(content);
244
243
  this.markDirty();
245
244
  }
@@ -256,6 +255,7 @@ export class ConversationManager extends SdkConversationManager {
256
255
  for (const key of this.messageKindRegistry.keys()) {
257
256
  if (key >= postUndoCount) this.messageKindRegistry.delete(key);
258
257
  }
258
+ this.userReceiptIndices.purgeFrom(postUndoCount);
259
259
  this.markDirty();
260
260
  }
261
261
  return result;
@@ -357,6 +357,7 @@ export class ConversationManager extends SdkConversationManager {
357
357
  this.messageLineRegistry = [];
358
358
  this.errorLineRegistry = [];
359
359
  this.messageKindRegistry = new Map();
360
+ this.userReceiptIndices.clear();
360
361
  this.streamingStartLine = -1;
361
362
  this._displayFromMessageIndex = 0; // full reset — show everything on next render
362
363
  }
@@ -478,12 +479,14 @@ export class ConversationManager extends SdkConversationManager {
478
479
  const displayStart = this._displayFromMessageIndex;
479
480
  const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
480
481
 
481
- // Tool messages ARE rendered (as collapsed blocks); this filter is only
482
- // for determining whether to show the splash screen (tool-only messages
483
- // don't count as visible conversation content for splash purposes).
484
- const displayMessages = visibleSnapshot.filter(
485
- (m) => m.role !== 'tool' && m.role !== 'system',
486
- );
482
+ // Tool/system messages aren't visible splash-purposes content, except a
483
+ // system message in userReceiptIndices. visibleSnapshot mirrors the
484
+ // snapshot 1:1 (only front-sliced), so index i is absolute displayStart+i.
485
+ const displayMessages = visibleSnapshot.filter((m, i) => {
486
+ if (m.role === 'tool') return false;
487
+ if (m.role === 'system') return this.userReceiptIndices.has(displayStart + i);
488
+ return true;
489
+ });
487
490
 
488
491
  if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
489
492
  this.addSplashScreen(width);
@@ -23,9 +23,8 @@
23
23
  */
24
24
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
- import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
26
+ import type { SessionSurface } from '@/runtime/index.ts';
27
27
 
28
- const { getUserSessionsDir } = operations;
29
28
 
30
29
  /** One recorded turn boundary — the rewind coordinator's per-turn anchor. */
31
30
  export interface TurnAnchor {
@@ -87,18 +86,18 @@ export function clearTurnAnchors(sessionId: string): void {
87
86
  // --- Cross-resume persistence -------------------------------------------------
88
87
  //
89
88
  // The TUI mirrors the in-memory registry to a sidecar next to the session file.
90
- // Surface is always 'tui' (the sessions the TUI reads/writes live under the same
91
- // scoped directory), matching how the session JSONL itself is persisted.
92
- const ANCHOR_SIDECAR_SURFACE = 'tui';
89
+ // The directory comes off the caller's SessionSurface (`surface.sessionsDir`),
90
+ // the same handle the session JSONL itself is written through — so the sidecar
91
+ // and its session can never end up in different directories.
93
92
  const ANCHOR_SIDECAR_VERSION = 1;
94
93
 
95
94
  /** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
96
- function anchorSidecarPath(sessionId: string, workingDirectory: string): string | null {
97
- if (!sessionId || !workingDirectory) return null;
95
+ function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
96
+ if (!sessionId || !surface.sessionsDir) return null;
98
97
  // A sessionId is a filename component; refuse anything with path separators so
99
98
  // a malformed id can never escape the sessions directory.
100
99
  if (/[\\/]/.test(sessionId)) return null;
101
- return join(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), `${sessionId}.anchors.json`);
100
+ return join(surface.sessionsDir, `${sessionId}.anchors.json`);
102
101
  }
103
102
 
104
103
  function isTurnAnchor(value: unknown): value is TurnAnchor {
@@ -118,13 +117,13 @@ function isTurnAnchor(value: unknown): value is TurnAnchor {
118
117
  * torn write must never break the turn that triggered it. Called after each
119
118
  * `recordTurnAnchor` at TURN_COMPLETED.
120
119
  */
121
- export function persistTurnAnchors(sessionId: string, workingDirectory: string): void {
122
- const path = anchorSidecarPath(sessionId, workingDirectory);
120
+ export function persistTurnAnchors(sessionId: string, surface: SessionSurface): void {
121
+ const path = anchorSidecarPath(sessionId, surface);
123
122
  if (!path) return;
124
123
  const list = registry.get(sessionId) ?? [];
125
124
  if (list.length === 0) return; // nothing to mirror yet
126
125
  try {
127
- mkdirSync(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), { recursive: true });
126
+ mkdirSync(surface.sessionsDir, { recursive: true });
128
127
  const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
129
128
  const tmp = `${path}.tmp-${process.pid}`;
130
129
  writeFileSync(tmp, payload);
@@ -140,8 +139,8 @@ export function persistTurnAnchors(sessionId: string, workingDirectory: string):
140
139
  * unreadable). Idempotent via `recordTurnAnchor`'s per-turnId dedup, so a resume
141
140
  * that later re-records the same turn keeps a single entry.
142
141
  */
143
- export function restoreTurnAnchors(sessionId: string, workingDirectory: string): number {
144
- const path = anchorSidecarPath(sessionId, workingDirectory);
142
+ export function restoreTurnAnchors(sessionId: string, surface: SessionSurface): number {
143
+ const path = anchorSidecarPath(sessionId, surface);
145
144
  if (!path || !existsSync(path)) return 0;
146
145
  try {
147
146
  const parsed = JSON.parse(readFileSync(path, 'utf8')) as { anchors?: unknown };
@@ -35,6 +35,7 @@
35
35
  import { journalPathFor, openTranscriptJournal, replayJournal } from './transcript-journal.ts';
36
36
  import type { ConversationManager } from './conversation.ts';
37
37
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
38
+ import type { SessionSurface } from '@/runtime/index.ts';
38
39
 
39
40
  // ─── Types ──────────────────────────────────────────────────────────────────
40
41
 
@@ -145,9 +146,65 @@ export function replayJournalIntoConversation(
145
146
  */
146
147
  export function replayJournalForSession(
147
148
  options: Omit<ReplayIntoConversationOptions, 'journalPath'> & {
148
- readonly homeDirectory: string;
149
+ readonly surface: SessionSurface;
149
150
  },
150
151
  ): ReplayIntoConversationResult {
151
- const journalPath = journalPathFor(options.homeDirectory, options.sessionId);
152
+ const journalPath = journalPathFor(options.surface, options.sessionId);
152
153
  return replayJournalIntoConversation({ ...options, journalPath });
153
154
  }
155
+
156
+ // ─── Prompted crash-snapshot restore ────────────────────────────────────────
157
+
158
+ /** What a crash-recovery snapshot carries back from disk. */
159
+ export interface RecoverySnapshotPayload {
160
+ readonly messages: Array<Record<string, unknown>>;
161
+ readonly title?: string | undefined;
162
+ readonly titleSource?: string | undefined;
163
+ readonly timestamp?: number | undefined;
164
+ }
165
+
166
+ export interface ApplyRecoverySnapshotResult {
167
+ /** Messages the conversation holds once the snapshot (plus any journal tail) is in place. */
168
+ readonly messageCount: number;
169
+ /** Journal records that post-dated the snapshot and were folded in on top of it. */
170
+ readonly journalReplay: ReplayIntoConversationResult;
171
+ }
172
+
173
+ /**
174
+ * Apply a crash-recovery snapshot the user explicitly asked to resume.
175
+ *
176
+ * This is deliberately the same sequence `resumeSessionCore` runs for a saved
177
+ * session — reset, fromJSON, rebuildHistory, then fold in any journal records
178
+ * newer than the snapshot — with one difference: the messages come from the
179
+ * recovery file the SDK just handed back (`consumeRecovery`) instead of the
180
+ * session store, because a session that crashed before its first clean save
181
+ * has no entry in the store to load. Nothing here decides WHETHER to restore;
182
+ * the caller has already asked the user (see runtime/recovery-prompt.ts).
183
+ */
184
+ export function applyRecoverySnapshot(options: {
185
+ readonly snapshot: RecoverySnapshotPayload;
186
+ readonly sessionId: string;
187
+ readonly conversation: ConversationManager;
188
+ readonly surface: SessionSurface;
189
+ readonly persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
190
+ }): ApplyRecoverySnapshotResult {
191
+ const { snapshot, sessionId, conversation, surface, persistSnapshot } = options;
192
+
193
+ conversation.resetAll();
194
+ conversation.fromJSON({
195
+ messages: snapshot.messages as never[],
196
+ title: snapshot.title,
197
+ titleSource: snapshot.titleSource as never,
198
+ });
199
+ conversation.rebuildHistory();
200
+
201
+ const journalReplay = replayJournalForSession({
202
+ surface,
203
+ sessionId,
204
+ snapshotTimestamp: snapshot.timestamp ?? 0,
205
+ conversation,
206
+ persistSnapshot,
207
+ });
208
+
209
+ return { messageCount: conversation.getMessageCount(), journalReplay };
210
+ }