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