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