@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/management.ts +1 -1
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +1 -1
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +7 -3
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- 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 {
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
48
|
-
* the same count each member
|
|
49
|
-
* the group total
|
|
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')
|
|
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
|
+
}
|
package/src/core/conversation.ts
CHANGED
|
@@ -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 -
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
const displayMessages = visibleSnapshot.filter(
|
|
485
|
-
(m
|
|
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 {
|
|
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
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
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,
|
|
97
|
-
if (!sessionId || !
|
|
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(
|
|
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,
|
|
122
|
-
const path = anchorSidecarPath(sessionId,
|
|
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(
|
|
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,
|
|
144
|
-
const path = anchorSidecarPath(sessionId,
|
|
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
|
|
149
|
+
readonly surface: SessionSurface;
|
|
149
150
|
},
|
|
150
151
|
): ReplayIntoConversationResult {
|
|
151
|
-
const journalPath = journalPathFor(options.
|
|
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
|
+
}
|