@north-light/crouter 0.3.170 → 0.3.171
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/dist/build-root.d.ts +1 -16
- package/dist/build-root.js +1 -25
- package/dist/builtin-memory/insights/capture.md +66 -0
- package/dist/builtin-memory/insights/init.md +45 -0
- package/dist/builtin-memory/internal/plugins.md +12 -11
- package/dist/cli.js +2 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
- package/dist/clients/attach/__tests__/context-message.test.js +50 -3
- package/dist/clients/attach/__tests__/diagram.test.js +1 -1
- package/dist/clients/attach/chrome/header.d.ts +10 -0
- package/dist/clients/attach/chrome/header.js +35 -0
- package/dist/clients/attach/chrome/status-line.d.ts +3 -0
- package/dist/clients/attach/chrome/status-line.js +5 -0
- package/dist/clients/attach/chrome/widgets.js +7 -18
- package/dist/clients/attach/input/controller.d.ts +10 -0
- package/dist/clients/attach/input/controller.js +3 -0
- package/dist/clients/attach/overlays/help.d.ts +33 -0
- package/dist/clients/attach/overlays/help.js +204 -0
- package/dist/clients/attach/render/chat-view.d.ts +81 -9
- package/dist/clients/attach/render/chat-view.js +434 -90
- package/dist/clients/attach/render/context-message.js +3 -3
- package/dist/clients/attach/render/diagram.js +2 -2
- package/dist/clients/attach/render/measured-container.d.ts +94 -0
- package/dist/clients/attach/render/measured-container.js +307 -0
- package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
- package/dist/clients/attach/render/transcript-copy.js +81 -0
- package/dist/clients/attach/render/viewport.d.ts +29 -0
- package/dist/clients/attach/render/viewport.js +160 -0
- package/dist/clients/attach/session/bindings.d.ts +5 -1
- package/dist/clients/attach/session/bindings.js +16 -0
- package/dist/clients/attach/session/context.d.ts +5 -3
- package/dist/clients/attach/session/frame.d.ts +34 -0
- package/dist/clients/attach/session/frame.js +173 -0
- package/dist/clients/attach/session/frames.d.ts +2 -0
- package/dist/clients/attach/session/frames.js +3 -0
- package/dist/clients/attach/session/input-wiring.d.ts +10 -4
- package/dist/clients/attach/session/input-wiring.js +13 -13
- package/dist/clients/attach/session/keys.d.ts +4 -1
- package/dist/clients/attach/session/keys.js +9 -4
- package/dist/clients/attach/session/layout.d.ts +10 -8
- package/dist/clients/attach/session/layout.js +32 -31
- package/dist/clients/attach/session/mouse.d.ts +15 -0
- package/dist/clients/attach/session/mouse.js +61 -0
- package/dist/clients/attach/slash/dispatch.d.ts +10 -0
- package/dist/clients/attach/slash/dispatch.js +35 -2
- package/dist/clients/attach/viewer.js +605 -581
- package/dist/clients/web/dev-server.d.ts +4 -1
- package/dist/clients/web/dev-server.js +8 -19
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/human/prompts.js +2 -1
- package/dist/commands/pkg/plugin-manage.js +143 -68
- package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
- package/dist/commands/sys/setup-core.js +21 -16
- package/dist/core/clipboard-text.d.ts +8 -0
- package/dist/core/clipboard-text.js +18 -0
- package/dist/core/command-manifests/registry.d.ts +1 -3
- package/dist/core/command-plugins/bundle.d.ts +29 -0
- package/dist/core/command-plugins/bundle.js +198 -0
- package/dist/core/command-plugins/discovery.d.ts +0 -8
- package/dist/core/command-plugins/discovery.js +2 -39
- package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
- package/dist/core/command-plugins/transport/http-fetch.js +17 -95
- package/dist/core/command.d.ts +1 -13
- package/dist/core/command.js +1 -16
- package/dist/core/config.js +2 -1
- package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +8 -0
- package/dist/core/runtime/broker-protocol.d.ts +13 -1
- package/dist/core/runtime/broker.js +213 -5
- package/dist/core/runtime/canvas-extensions.d.ts +3 -0
- package/dist/core/runtime/canvas-extensions.js +3 -0
- package/dist/core/runtime/front-door.js +4 -4
- package/dist/core/runtime/kickoff.d.ts +1 -5
- package/dist/core/runtime/kickoff.js +1 -5
- package/dist/core/runtime/naming.d.ts +7 -0
- package/dist/core/runtime/naming.js +5 -2
- package/dist/core/runtime/node-read.js +15 -0
- package/dist/core/runtime/package-health.d.ts +6 -21
- package/dist/core/runtime/package-health.js +12 -123
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +3 -3
- package/dist/core/runtime/tool-group-summary.d.ts +19 -0
- package/dist/core/runtime/tool-group-summary.js +117 -0
- package/dist/core/termrender/code-doc.d.ts +12 -0
- package/dist/core/termrender/code-doc.js +91 -0
- package/dist/core/termrender/display.d.ts +12 -0
- package/dist/core/termrender/display.js +19 -0
- package/dist/core/termrender/termrender.d.ts +73 -0
- package/dist/core/termrender/termrender.js +795 -0
- package/dist/core/termrender/version.d.ts +1 -0
- package/dist/core/termrender/version.js +1 -0
- package/dist/core/user-settings.d.ts +14 -0
- package/dist/core/user-settings.js +20 -0
- package/dist/daemon/crtrd.js +5 -6
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -2
- package/dist/pi-extensions/canvas-structured-output.js +52 -8
- package/dist/pi-extensions/summary-tool.d.ts +33 -0
- package/dist/pi-extensions/summary-tool.js +62 -0
- package/dist/shared/generated-context.d.ts +19 -4
- package/dist/shared/generated-context.js +98 -7
- package/dist/shared/tool-groups.d.ts +23 -0
- package/dist/shared/tool-groups.js +60 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +3 -1
- package/runtime.lock.json +62 -3
- package/scripts/postinstall.mjs +14 -1
- package/dist/core/command-plugins/store.d.ts +0 -16
- package/dist/core/command-plugins/store.js +0 -64
- package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
- package/dist/core/runtime/fault-recovery-nudge.js +0 -3
|
@@ -22,11 +22,14 @@
|
|
|
22
22
|
// path while builtin tools, diffs, and partial-result streaming keep working.
|
|
23
23
|
import { Container, Loader, Spacer, Text } from '@earendil-works/pi-tui';
|
|
24
24
|
import { AssistantMessageComponent, BashExecutionComponent, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomMessageComponent, parseSkillBlock, SkillInvocationMessageComponent, ToolExecutionComponent, UserMessageComponent, } from '@earendil-works/pi-coding-agent';
|
|
25
|
+
import { ICON } from '../../../core/inspector/model.js';
|
|
26
|
+
import { TOOL_ICON } from '../../../core/preview-registry.js';
|
|
25
27
|
import { attachMarkdownTheme } from '../config.js';
|
|
26
28
|
import { ContextMessageComponent } from './context-message.js';
|
|
27
29
|
import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashToolDefinition, } from './crtr-output.js';
|
|
28
30
|
import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../../core/runtime/session-cycles.js';
|
|
29
31
|
import { generatedContextPresentation } from '../../../shared/generated-context.js';
|
|
32
|
+
import { assistantVisibleText, isTrueUserMessage, normalizeToolGroupSummary, normalizeToolGroupSummaryRecord, } from '../../../shared/tool-groups.js';
|
|
30
33
|
import { transformMessageDiagrams } from './diagram.js';
|
|
31
34
|
import { styleAttachMarkdownHeadings, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-headings.js';
|
|
32
35
|
import { createCrtrEditToolDefinition } from './edit-diff.js';
|
|
@@ -35,6 +38,7 @@ import { ringCompletionBell } from '../chrome/completion-bell.js';
|
|
|
35
38
|
import { extractFilePaths } from '../visible-paths.js';
|
|
36
39
|
import { keepWhenCondensed, liveRegionStart } from './condensed-history.js';
|
|
37
40
|
import { ViewerAssistantMessageComponent } from './assistant-message.js';
|
|
41
|
+
import { CompositeViewportSource, MeasuredContainer, } from './measured-container.js';
|
|
38
42
|
import { readConfig, updateRawConfigAtomically } from '../../../core/config.js';
|
|
39
43
|
import { DEFAULT_CONDENSED_HISTORY, DEFAULT_LIVE_CYCLES } from '../../../types.js';
|
|
40
44
|
/** Theme-derived chrome colors (spinner / dim / error) are supplied by the
|
|
@@ -85,6 +89,17 @@ function readFoldFinishedTools() {
|
|
|
85
89
|
return false;
|
|
86
90
|
}
|
|
87
91
|
}
|
|
92
|
+
/** Whether this attach should start folded tool groups on their broker-generated
|
|
93
|
+
* summaries. The setting is deliberately attach-local: the broker remains the
|
|
94
|
+
* single summary producer and every viewer independently chooses its display. */
|
|
95
|
+
function readSummarizeToolCalls() {
|
|
96
|
+
try {
|
|
97
|
+
return readConfig('user').summarize_tool_calls;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
88
103
|
/** Persist the fold view setting to user config. Best-effort: a viewer must never
|
|
89
104
|
* die because the config file is unwritable — the in-viewer toggle still applies. */
|
|
90
105
|
function persistFoldFinishedTools(value) {
|
|
@@ -165,6 +180,25 @@ class FoldedToolSeparator extends Spacer {
|
|
|
165
180
|
this.setLines(this.folded && this.separatesToolGroup ? 1 : 0);
|
|
166
181
|
}
|
|
167
182
|
}
|
|
183
|
+
/** The broker-generated recap of one maximal non-prose run. It remains a
|
|
184
|
+
* direct history child so the render cache's per-child identity stays valid while
|
|
185
|
+
* the group members are hidden. The title is viewer-owned and deterministic;
|
|
186
|
+
* only the bullets and generated counts come from the broker. */
|
|
187
|
+
class GroupSummaryLine extends Text {
|
|
188
|
+
setSummary(summary, toolCount, dim) {
|
|
189
|
+
if (!summary) {
|
|
190
|
+
this.setText('');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const count = (value, singular, plural) => `${value} ${value === 1 ? singular : plural}`;
|
|
194
|
+
const stats = [`${TOOL_ICON.bash} ${count(toolCount, 'tool call', 'tool calls')}`];
|
|
195
|
+
if (summary.nodesSpawned >= 1)
|
|
196
|
+
stats.push(`${ICON.graph} ${count(summary.nodesSpawned, 'node spawned', 'nodes spawned')}`);
|
|
197
|
+
if (summary.filesEdited >= 1)
|
|
198
|
+
stats.push(`${TOOL_ICON.edit} ${count(summary.filesEdited, 'file edited', 'files edited')}`);
|
|
199
|
+
this.setText([dim(`✦ ${stats.join(' · ')}`), ...summary.bullets.map((bullet) => dim(` • ${bullet.trim()}`))].join('\n'));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
168
202
|
class MinimizableToolComponent extends ToolExecutionComponent {
|
|
169
203
|
minimized = false;
|
|
170
204
|
expandedState = false;
|
|
@@ -207,51 +241,18 @@ class MinimizableToolComponent extends ToolExecutionComponent {
|
|
|
207
241
|
}
|
|
208
242
|
}
|
|
209
243
|
const defaultErrorStyle = (s) => `\x1b[31m${s}\x1b[39m`;
|
|
210
|
-
/** Keep settled conversation history out of picker/editor render hot paths. The
|
|
211
|
-
* root TUI still compares the cached lines, but it no longer re-renders every
|
|
212
|
-
* markdown/tool component for each repeated navigation key. */
|
|
213
|
-
class RenderCacheContainer extends Container {
|
|
214
|
-
childCache = new Map();
|
|
215
|
-
markDirty(component) {
|
|
216
|
-
if (component)
|
|
217
|
-
this.childCache.delete(component);
|
|
218
|
-
else
|
|
219
|
-
this.childCache.clear();
|
|
220
|
-
}
|
|
221
|
-
removeChild(component) {
|
|
222
|
-
super.removeChild(component);
|
|
223
|
-
this.childCache.delete(component);
|
|
224
|
-
}
|
|
225
|
-
clear() {
|
|
226
|
-
super.clear();
|
|
227
|
-
this.childCache.clear();
|
|
228
|
-
}
|
|
229
|
-
invalidate() {
|
|
230
|
-
super.invalidate();
|
|
231
|
-
this.childCache.clear();
|
|
232
|
-
}
|
|
233
|
-
render(width) {
|
|
234
|
-
const lines = [];
|
|
235
|
-
for (const child of this.children) {
|
|
236
|
-
let cached = this.childCache.get(child);
|
|
237
|
-
if (!cached || cached.width !== width) {
|
|
238
|
-
cached = { width, lines: child.render(width) };
|
|
239
|
-
this.childCache.set(child, cached);
|
|
240
|
-
}
|
|
241
|
-
lines.push(...cached.lines);
|
|
242
|
-
}
|
|
243
|
-
return lines;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
244
|
export class ChatView {
|
|
247
245
|
tui;
|
|
248
246
|
/** The chat scroll area handed in by the attach command. */
|
|
249
247
|
container;
|
|
250
248
|
/** Settled history is cached independently from the live status area so picker
|
|
251
249
|
* navigation does not re-render a long conversation on every key repeat. */
|
|
252
|
-
historyContainer = new
|
|
250
|
+
historyContainer = new MeasuredContainer();
|
|
253
251
|
/** Activity/status area below history. Holds working/compaction/retry loaders. */
|
|
254
|
-
statusContainer = new
|
|
252
|
+
statusContainer = new MeasuredContainer();
|
|
253
|
+
/** Optional banner, above history and status in the viewport's flat source. */
|
|
254
|
+
bannerContainer;
|
|
255
|
+
transcript;
|
|
255
256
|
/** Where `append()` puts a component. Normally `historyContainer`; retargeted to
|
|
256
257
|
* a nested container while condensed history is being built, so the same
|
|
257
258
|
* message-rendering path serves both regions. */
|
|
@@ -271,6 +272,14 @@ export class ChatView {
|
|
|
271
272
|
* call line. Independent of `toolOutputExpanded` — it survives an expand/collapse
|
|
272
273
|
* and only ever applies to tool calls that are done. */
|
|
273
274
|
foldSettledTools = readFoldFinishedTools();
|
|
275
|
+
/** Summary mode only changes Ctrl+O when both persistent settings are on. */
|
|
276
|
+
summarizeToolCalls = readSummarizeToolCalls();
|
|
277
|
+
showGroupSummaries = this.summarizeToolCalls && this.foldSettledTools;
|
|
278
|
+
/** Groups exist only while building/rendering the full-fidelity live region. */
|
|
279
|
+
groups = [];
|
|
280
|
+
openGroup;
|
|
281
|
+
groupSummaries = new Map();
|
|
282
|
+
streamingWasGroupMember = false;
|
|
274
283
|
onFooterEvent;
|
|
275
284
|
/** Theme-derived colorizers for the activity/status area. */
|
|
276
285
|
spinnerStyle;
|
|
@@ -320,6 +329,12 @@ export class ChatView {
|
|
|
320
329
|
transcriptTexts = [];
|
|
321
330
|
/** Existing file paths found anywhere in the rendered transcript, newest first. */
|
|
322
331
|
transcriptFilePaths = [];
|
|
332
|
+
/** Canonical copyable transcript blocks, in their rendered visual order. */
|
|
333
|
+
copyBlockRecords = [];
|
|
334
|
+
copyIndex = new Map();
|
|
335
|
+
/** Registrations made while building condensed history are collapsed onto its
|
|
336
|
+
* outer container once that container becomes a top-level viewport block. */
|
|
337
|
+
condensedCopyBlocks = new WeakMap();
|
|
323
338
|
constructor(tui, container, opts = {}) {
|
|
324
339
|
this.tui = tui;
|
|
325
340
|
this.container = container;
|
|
@@ -338,15 +353,25 @@ export class ChatView {
|
|
|
338
353
|
// Banner sits above history and OUTSIDE it, so resetChat() (which clears only
|
|
339
354
|
// historyContainer) never wipes it — it persists across every welcome snapshot.
|
|
340
355
|
if (opts.banner && opts.banner.length > 0) {
|
|
341
|
-
const
|
|
342
|
-
|
|
356
|
+
const banner = new MeasuredContainer();
|
|
357
|
+
banner.addChild(new Spacer(1));
|
|
343
358
|
for (const line of opts.banner)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
this.
|
|
359
|
+
banner.addChild(new Text(line, 1, 0));
|
|
360
|
+
banner.addChild(new Spacer(1));
|
|
361
|
+
this.bannerContainer = banner;
|
|
362
|
+
this.container.addChild(banner);
|
|
347
363
|
}
|
|
348
364
|
this.container.addChild(this.historyContainer);
|
|
349
365
|
this.container.addChild(this.statusContainer);
|
|
366
|
+
this.transcript = new CompositeViewportSource([
|
|
367
|
+
...(this.bannerContainer ? [this.bannerContainer] : []),
|
|
368
|
+
this.historyContainer,
|
|
369
|
+
this.statusContainer,
|
|
370
|
+
]);
|
|
371
|
+
}
|
|
372
|
+
/** The transcript as one flat ordered block list for the viewport. */
|
|
373
|
+
transcriptSource() {
|
|
374
|
+
return this.transcript;
|
|
350
375
|
}
|
|
351
376
|
// -------------------------------------------------------------------------
|
|
352
377
|
// Catch-up: render the welcome snapshot, then live events resume.
|
|
@@ -365,6 +390,7 @@ export class ChatView {
|
|
|
365
390
|
this.applyingSnapshot = true;
|
|
366
391
|
this.deferredFrames.length = 0;
|
|
367
392
|
this.resetChat();
|
|
393
|
+
this.seedToolGroupSummaries(snapshot.toolGroupSummaries);
|
|
368
394
|
const renderedPendingTools = new Map();
|
|
369
395
|
const messages = snapshot.messages;
|
|
370
396
|
const lastIndex = messages.length - 1;
|
|
@@ -379,7 +405,7 @@ export class ChatView {
|
|
|
379
405
|
if (generation !== this.snapshotGeneration)
|
|
380
406
|
return;
|
|
381
407
|
if (condensed)
|
|
382
|
-
this.
|
|
408
|
+
this.appendCondensedBlock(condensed);
|
|
383
409
|
}
|
|
384
410
|
for (let i = liveStart; i < messages.length; i++) {
|
|
385
411
|
// Yield to the event loop every SNAPSHOT_YIELD_EVERY messages so queued
|
|
@@ -401,7 +427,7 @@ export class ChatView {
|
|
|
401
427
|
if (message.role === 'assistant') {
|
|
402
428
|
// Keep the `/copy` source current from the catch-up transcript too, not
|
|
403
429
|
// only the live stream (you usually attach AFTER the last reply landed).
|
|
404
|
-
const text =
|
|
430
|
+
const text = assistantVisibleText(message);
|
|
405
431
|
if (text.trim()) {
|
|
406
432
|
this.lastAssistantText = text;
|
|
407
433
|
this.pushTranscriptText(text);
|
|
@@ -416,23 +442,37 @@ export class ChatView {
|
|
|
416
442
|
snapshot.state.isStreaming &&
|
|
417
443
|
message.stopReason !== 'aborted' &&
|
|
418
444
|
message.stopReason !== 'error';
|
|
445
|
+
const hasProse = assistantVisibleText(message).trim() !== '';
|
|
446
|
+
const toolCalls = this.assistantToolCalls(message);
|
|
447
|
+
const persistedGroupKey = toolCalls[0]?.id;
|
|
448
|
+
// A broker restart resets its live grouping state. On reattach, the
|
|
449
|
+
// persisted first-call key is the durable boundary that keeps snapshot
|
|
450
|
+
// reconstruction from joining the new group to pre-restart tools.
|
|
451
|
+
if (hasProse ||
|
|
452
|
+
(persistedGroupKey !== undefined &&
|
|
453
|
+
this.groupSummaries.has(persistedGroupKey) &&
|
|
454
|
+
this.openGroup?.key !== undefined &&
|
|
455
|
+
this.openGroup.key !== persistedGroupKey)) {
|
|
456
|
+
this.closeOpenGroup();
|
|
457
|
+
}
|
|
419
458
|
let separator;
|
|
420
459
|
if (isStreamingTail) {
|
|
421
460
|
this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
|
|
422
|
-
this.
|
|
461
|
+
this.appendAssistantPart(this.streamingComponent, !hasProse);
|
|
462
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', '');
|
|
463
|
+
this.streamingWasGroupMember = !hasProse;
|
|
423
464
|
this.streamingComponent.updateContent(styleAttachMessageMarkdown(message));
|
|
424
|
-
|
|
465
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(message));
|
|
466
|
+
separator = this.streamingToolSeparator = this.appendAssistantSeparator(!hasProse);
|
|
425
467
|
}
|
|
426
468
|
else {
|
|
427
|
-
separator = this.appendAssistantMessage(message);
|
|
469
|
+
separator = this.appendAssistantMessage(message, !hasProse);
|
|
428
470
|
}
|
|
429
|
-
const toolCalls = this.assistantToolCalls(message);
|
|
430
471
|
if (separator)
|
|
431
472
|
this.setAssistantSeparatorContent(separator, message);
|
|
432
473
|
for (const content of toolCalls) {
|
|
433
474
|
this.pushTranscriptValue(content.arguments);
|
|
434
|
-
const component = this.
|
|
435
|
-
this.append(component);
|
|
475
|
+
const component = this.appendToolComponent(content.name, content.id, content.arguments);
|
|
436
476
|
// (isStreamingTail is never aborted/error, so its tools fall through to
|
|
437
477
|
// renderedPendingTools → pendingTools, where live tool events find them.)
|
|
438
478
|
if (message.stopReason === 'aborted' || message.stopReason === 'error') {
|
|
@@ -440,6 +480,8 @@ export class ChatView {
|
|
|
440
480
|
? 'Interrupted'
|
|
441
481
|
: (message.errorMessage ?? 'Error');
|
|
442
482
|
component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
|
|
483
|
+
this.registerCopyBlock(component, 'tool', errorMessage);
|
|
484
|
+
this.settleGroupTool(component);
|
|
443
485
|
}
|
|
444
486
|
else {
|
|
445
487
|
renderedPendingTools.set(content.id, component);
|
|
@@ -451,7 +493,9 @@ export class ChatView {
|
|
|
451
493
|
const component = renderedPendingTools.get(message.toolCallId);
|
|
452
494
|
if (component) {
|
|
453
495
|
component.updateResult(message);
|
|
496
|
+
this.registerCopyBlock(component, 'tool', this.toolResultText(message));
|
|
454
497
|
this.historyContainer.markDirty(component);
|
|
498
|
+
this.settleGroupTool(component);
|
|
455
499
|
renderedPendingTools.delete(message.toolCallId);
|
|
456
500
|
}
|
|
457
501
|
}
|
|
@@ -464,8 +508,7 @@ export class ChatView {
|
|
|
464
508
|
}
|
|
465
509
|
// A rebuild constructs fresh components, which are born unfolded; re-apply the
|
|
466
510
|
// setting so a reconnect/welcome does not silently unfold the transcript.
|
|
467
|
-
|
|
468
|
-
this.applyToolDisplay();
|
|
511
|
+
this.applyToolDisplay();
|
|
469
512
|
// If we attached mid-turn, the `agent_start` that spins up the working loader
|
|
470
513
|
// already fired before we connected — restore it from the snapshot's streaming
|
|
471
514
|
// state so an actively-working node shows the spinner right away.
|
|
@@ -492,7 +535,9 @@ export class ChatView {
|
|
|
492
535
|
async buildCondensedBlock(messages, generation) {
|
|
493
536
|
const condensed = new Container();
|
|
494
537
|
const previousTarget = this.appendTarget;
|
|
538
|
+
const copyBlocks = [];
|
|
495
539
|
this.appendTarget = condensed;
|
|
540
|
+
this.condensedCopyBlocks.set(condensed, copyBlocks);
|
|
496
541
|
try {
|
|
497
542
|
let built = 0;
|
|
498
543
|
for (const message of messages) {
|
|
@@ -513,8 +558,9 @@ export class ChatView {
|
|
|
513
558
|
}
|
|
514
559
|
finally {
|
|
515
560
|
this.appendTarget = previousTarget;
|
|
561
|
+
this.condensedCopyBlocks.delete(condensed);
|
|
516
562
|
}
|
|
517
|
-
return condensed.children.length > 0 ? condensed : undefined;
|
|
563
|
+
return condensed.children.length > 0 ? { component: condensed, copyBlocks } : undefined;
|
|
518
564
|
}
|
|
519
565
|
/** Defer a live broker frame when a snapshot build is in flight. Returns true
|
|
520
566
|
* (and enqueues the replay closure) while building so the caller returns
|
|
@@ -535,12 +581,16 @@ export class ChatView {
|
|
|
535
581
|
}
|
|
536
582
|
/** Clear live turn state after the broker reports completion or the viewer observes dormancy. */
|
|
537
583
|
settleRun() {
|
|
584
|
+
for (const component of this.pendingTools.values())
|
|
585
|
+
this.settleGroupTool(component);
|
|
538
586
|
if (this.streamingComponent) {
|
|
539
587
|
this.historyContainer.removeChild(this.streamingComponent);
|
|
588
|
+
this.unregisterCopyBlock(this.streamingComponent);
|
|
540
589
|
this.streamingComponent = undefined;
|
|
541
590
|
}
|
|
542
591
|
this.streamingToolSeparator = undefined;
|
|
543
592
|
this.pendingTools.clear();
|
|
593
|
+
this.applyToolDisplay();
|
|
544
594
|
this.setActivity(undefined);
|
|
545
595
|
this.settleBashComponent();
|
|
546
596
|
}
|
|
@@ -555,36 +605,48 @@ export class ChatView {
|
|
|
555
605
|
// Pure client-side render state — no broker round-trip — ported from pi's
|
|
556
606
|
// `setToolsExpanded` / `toggleThinkingBlockVisibility`.
|
|
557
607
|
// -------------------------------------------------------------------------
|
|
558
|
-
/** Ctrl+O
|
|
559
|
-
*
|
|
560
|
-
* the caller can surface a notice. Condensed cycles hold no tool components, so
|
|
561
|
-
* they are unaffected by design. */
|
|
608
|
+
/** Ctrl+O prefers summaries when the user enabled both folding and summary
|
|
609
|
+
* generation. Otherwise it remains pi's global output expansion toggle. */
|
|
562
610
|
toggleToolsExpanded() {
|
|
611
|
+
if (this.summarizeToolCalls && this.foldSettledTools) {
|
|
612
|
+
this.showGroupSummaries = !this.showGroupSummaries;
|
|
613
|
+
this.toolOutputExpanded = !this.showGroupSummaries;
|
|
614
|
+
this.applyToolDisplay();
|
|
615
|
+
return { kind: 'summaries', shown: this.showGroupSummaries };
|
|
616
|
+
}
|
|
563
617
|
this.toolOutputExpanded = !this.toolOutputExpanded;
|
|
564
618
|
this.applyToolDisplay();
|
|
565
|
-
return this.toolOutputExpanded;
|
|
619
|
+
return { kind: 'output', expanded: this.toolOutputExpanded };
|
|
566
620
|
}
|
|
567
621
|
/** The `fold finished tool calls` view setting (`/fold-tools`, Alt+C → w → f).
|
|
568
622
|
* Returns the new state so the caller can surface a notice. */
|
|
569
623
|
toggleFoldSettledTools() {
|
|
570
624
|
this.foldSettledTools = !this.foldSettledTools;
|
|
571
625
|
persistFoldFinishedTools(this.foldSettledTools);
|
|
626
|
+
// Turning folding back on restores the configured default (summaries),
|
|
627
|
+
// rather than preserving a stale Ctrl+O choice from an unfolded view.
|
|
628
|
+
if (this.summarizeToolCalls && this.foldSettledTools)
|
|
629
|
+
this.showGroupSummaries = true;
|
|
572
630
|
this.applyToolDisplay();
|
|
573
631
|
return this.foldSettledTools;
|
|
574
632
|
}
|
|
575
633
|
/** Push both display settings onto every rendered component. A tool call still in
|
|
576
|
-
*
|
|
577
|
-
*
|
|
634
|
+
* flight is never hidden or minimized. Only a settled group with a broker-known
|
|
635
|
+
* summary collapses; incomplete/failed generation leaves its folded call lines. */
|
|
578
636
|
applyToolDisplay() {
|
|
579
637
|
const inFlight = new Set(this.pendingTools.values());
|
|
580
|
-
this.
|
|
638
|
+
for (const group of this.groups)
|
|
639
|
+
for (const tool of group.inFlight)
|
|
640
|
+
inFlight.add(tool);
|
|
581
641
|
for (const child of this.historyContainer.children) {
|
|
582
|
-
if (child instanceof MinimizableToolComponent)
|
|
642
|
+
if (child instanceof MinimizableToolComponent)
|
|
583
643
|
child.setMinimized(this.foldSettledTools && !inFlight.has(child));
|
|
584
|
-
}
|
|
585
644
|
if (isExpandable(child))
|
|
586
645
|
child.setExpanded(this.toolOutputExpanded);
|
|
587
646
|
}
|
|
647
|
+
for (const group of this.groups)
|
|
648
|
+
this.applyGroupDisplay(group);
|
|
649
|
+
this.updateFoldedToolSeparators(inFlight);
|
|
588
650
|
this.historyContainer.markDirty();
|
|
589
651
|
this.tui.requestRender();
|
|
590
652
|
}
|
|
@@ -615,6 +677,8 @@ export class ChatView {
|
|
|
615
677
|
return;
|
|
616
678
|
switch (event.type) {
|
|
617
679
|
case 'agent_start': {
|
|
680
|
+
for (const component of this.pendingTools.values())
|
|
681
|
+
this.settleGroupTool(component);
|
|
618
682
|
this.pendingTools.clear();
|
|
619
683
|
this.setActivity(this.makeLoader('Working...'));
|
|
620
684
|
break;
|
|
@@ -644,16 +708,23 @@ export class ChatView {
|
|
|
644
708
|
this.addMessageToChat(event.message);
|
|
645
709
|
}
|
|
646
710
|
else if (event.message.role === 'assistant') {
|
|
711
|
+
const hasProse = assistantVisibleText(event.message).trim() !== '';
|
|
712
|
+
if (hasProse)
|
|
713
|
+
this.closeOpenGroup();
|
|
647
714
|
this.streamingComponent = new ViewerAssistantMessageComponent(undefined, this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
|
|
648
|
-
this.
|
|
715
|
+
this.streamingWasGroupMember = !hasProse;
|
|
716
|
+
this.appendAssistantPart(this.streamingComponent, !hasProse);
|
|
717
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', '');
|
|
649
718
|
this.streamingComponent.updateContent(styleAttachMessageMarkdown(event.message));
|
|
650
|
-
this.
|
|
719
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
|
|
720
|
+
this.streamingToolSeparator = this.appendAssistantSeparator(!hasProse);
|
|
651
721
|
this.setAssistantSeparatorContent(this.streamingToolSeparator, event.message);
|
|
652
722
|
}
|
|
653
723
|
break;
|
|
654
724
|
}
|
|
655
725
|
case 'message_update': {
|
|
656
726
|
if (this.streamingComponent && event.message.role === 'assistant') {
|
|
727
|
+
this.promoteStreamingAssistantToBoundary(event.message);
|
|
657
728
|
// Render any ALREADY-COMPLETE diagram fence to a diagram mid-stream
|
|
658
729
|
// (transformMessageDiagrams only matches closed fences and is memoized,
|
|
659
730
|
// so an unclosed fence still streaming stays raw and settles later).
|
|
@@ -661,6 +732,7 @@ export class ChatView {
|
|
|
661
732
|
// of waiting for message_end — noticeable when more text follows it.
|
|
662
733
|
const updated = styleAttachMessageMarkdown(transformMessageDiagrams(event.message, this.renderWidth()));
|
|
663
734
|
this.streamingComponent.updateContent(updated);
|
|
735
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
|
|
664
736
|
this.historyContainer.markDirty(this.streamingComponent);
|
|
665
737
|
const toolCalls = this.assistantToolCalls(event.message);
|
|
666
738
|
if (this.streamingToolSeparator)
|
|
@@ -673,8 +745,7 @@ export class ChatView {
|
|
|
673
745
|
this.historyContainer.markDirty(existing);
|
|
674
746
|
}
|
|
675
747
|
else {
|
|
676
|
-
const component = this.
|
|
677
|
-
this.append(component);
|
|
748
|
+
const component = this.appendToolComponent(content.name, content.id, content.arguments);
|
|
678
749
|
this.pendingTools.set(content.id, component);
|
|
679
750
|
}
|
|
680
751
|
}
|
|
@@ -686,8 +757,11 @@ export class ChatView {
|
|
|
686
757
|
break;
|
|
687
758
|
// Stash the completed reply's text for `/copy` (skip aborted/error turns,
|
|
688
759
|
// whose text is partial or absent).
|
|
760
|
+
this.promoteStreamingAssistantToBoundary(event.message);
|
|
761
|
+
if (this.streamingComponent)
|
|
762
|
+
this.registerCopyBlock(this.streamingComponent, 'assistant', assistantVisibleText(event.message));
|
|
689
763
|
if (event.message.stopReason !== 'aborted' && event.message.stopReason !== 'error') {
|
|
690
|
-
const text =
|
|
764
|
+
const text = assistantVisibleText(event.message);
|
|
691
765
|
if (text.trim()) {
|
|
692
766
|
this.lastAssistantText = text;
|
|
693
767
|
this.pushTranscriptText(text);
|
|
@@ -715,6 +789,8 @@ export class ChatView {
|
|
|
715
789
|
const text = errorMessage ?? 'Error';
|
|
716
790
|
for (const component of this.pendingTools.values()) {
|
|
717
791
|
component.updateResult({ content: [{ type: 'text', text }], isError: true });
|
|
792
|
+
this.registerCopyBlock(component, 'tool', text);
|
|
793
|
+
this.settleGroupTool(component);
|
|
718
794
|
this.historyContainer.markDirty(component);
|
|
719
795
|
}
|
|
720
796
|
this.pendingTools.clear();
|
|
@@ -736,8 +812,7 @@ export class ChatView {
|
|
|
736
812
|
this.pushTranscriptValue(event.args);
|
|
737
813
|
let component = this.pendingTools.get(event.toolCallId);
|
|
738
814
|
if (!component) {
|
|
739
|
-
component = this.
|
|
740
|
-
this.append(component);
|
|
815
|
+
component = this.appendToolComponent(event.toolName, event.toolCallId, event.args);
|
|
741
816
|
this.pendingTools.set(event.toolCallId, component);
|
|
742
817
|
}
|
|
743
818
|
component.markExecutionStarted();
|
|
@@ -749,6 +824,7 @@ export class ChatView {
|
|
|
749
824
|
const component = this.pendingTools.get(event.toolCallId);
|
|
750
825
|
if (component) {
|
|
751
826
|
component.updateResult({ ...event.partialResult, isError: false }, true);
|
|
827
|
+
this.registerCopyBlock(component, 'tool', this.toolResultText(event.partialResult));
|
|
752
828
|
this.historyContainer.markDirty(component);
|
|
753
829
|
}
|
|
754
830
|
break;
|
|
@@ -758,11 +834,10 @@ export class ChatView {
|
|
|
758
834
|
const component = this.pendingTools.get(event.toolCallId);
|
|
759
835
|
if (component) {
|
|
760
836
|
component.updateResult({ ...event.result, isError: event.isError });
|
|
837
|
+
this.registerCopyBlock(component, 'tool', this.toolResultText(event.result));
|
|
761
838
|
this.pendingTools.delete(event.toolCallId);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
component.setMinimized(true);
|
|
765
|
-
this.updateFoldedToolSeparators();
|
|
839
|
+
this.settleGroupTool(component);
|
|
840
|
+
this.applyToolDisplay();
|
|
766
841
|
this.historyContainer.markDirty(component);
|
|
767
842
|
}
|
|
768
843
|
break;
|
|
@@ -822,7 +897,7 @@ export class ChatView {
|
|
|
822
897
|
break;
|
|
823
898
|
}
|
|
824
899
|
// No unconditional markDirty here: appends/removes invalidate through
|
|
825
|
-
//
|
|
900
|
+
// MeasuredContainer, and branches that mutate a child in place explicitly
|
|
826
901
|
// mark it dirty. Activity-only events leave settled history cached while the
|
|
827
902
|
// independent status loader animates.
|
|
828
903
|
this.tui.requestRender();
|
|
@@ -836,6 +911,7 @@ export class ChatView {
|
|
|
836
911
|
bashStart(command, excludeFromContext) {
|
|
837
912
|
if (this.deferIfBuilding(() => this.bashStart(command, excludeFromContext)))
|
|
838
913
|
return;
|
|
914
|
+
this.closeOpenGroup();
|
|
839
915
|
this.settleBashComponent();
|
|
840
916
|
let component;
|
|
841
917
|
component = new BashExecutionComponent(command, this.componentTui(() => component), excludeFromContext);
|
|
@@ -872,11 +948,18 @@ export class ChatView {
|
|
|
872
948
|
if (generated !== null &&
|
|
873
949
|
(message.role !== 'custom' || message.display === true)) {
|
|
874
950
|
this.pushTranscriptText(generated.body);
|
|
875
|
-
|
|
951
|
+
const component = new ContextMessageComponent(generated.body, this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`);
|
|
952
|
+
// Bearings and revive kickoffs only belong to an already-open run; they
|
|
953
|
+
// never create a summary block by themselves. Other injected context may.
|
|
954
|
+
if (generated.kind === 'bearings' || generated.kind === 'revive')
|
|
955
|
+
this.appendExistingGroupMember(component);
|
|
956
|
+
else
|
|
957
|
+
this.appendGroupMember(component);
|
|
876
958
|
return;
|
|
877
959
|
}
|
|
878
960
|
switch (message.role) {
|
|
879
961
|
case 'bashExecution': {
|
|
962
|
+
this.closeOpenGroup();
|
|
880
963
|
this.pushTranscriptText(message.output ?? '');
|
|
881
964
|
const component = new BashExecutionComponent(message.command, this.tui, message.excludeFromContext);
|
|
882
965
|
if (message.output)
|
|
@@ -894,7 +977,7 @@ export class ChatView {
|
|
|
894
977
|
if (message.display) {
|
|
895
978
|
this.pushTranscriptText(this.userMessageText(message));
|
|
896
979
|
if (message.customType === CRTR_OUTPUT_CUSTOM_TYPE) {
|
|
897
|
-
this.
|
|
980
|
+
this.appendGroupMember(new CrtrOutputMessageComponent(this.userMessageText(message), this.toolOutputExpanded, this.crtrOutputTheme()));
|
|
898
981
|
break;
|
|
899
982
|
}
|
|
900
983
|
// A refresh-yield cycle boundary (session-cycles.ts, synthetic — never
|
|
@@ -902,6 +985,7 @@ export class ChatView {
|
|
|
902
985
|
// message card, so scrolling through a multi-cycle node's full history
|
|
903
986
|
// reads as one conversation with clear seams between cycles.
|
|
904
987
|
if (message.customType === CRTR_CYCLE_DIVIDER_CUSTOM_TYPE) {
|
|
988
|
+
this.closeOpenGroup();
|
|
905
989
|
this.append(new Spacer(1));
|
|
906
990
|
this.append(new Text(this.dimStyle(`── ${this.userMessageText(message)} ──`), 1, 0));
|
|
907
991
|
this.append(new Spacer(1));
|
|
@@ -909,11 +993,12 @@ export class ChatView {
|
|
|
909
993
|
}
|
|
910
994
|
const component = new CustomMessageComponent(styleAttachMessageMarkdown(message), undefined, attachMarkdownTheme());
|
|
911
995
|
component.setExpanded(this.toolOutputExpanded);
|
|
912
|
-
this.
|
|
996
|
+
this.appendGroupMember(component);
|
|
913
997
|
}
|
|
914
998
|
break;
|
|
915
999
|
}
|
|
916
1000
|
case 'compactionSummary': {
|
|
1001
|
+
this.closeOpenGroup();
|
|
917
1002
|
this.append(new Spacer(1));
|
|
918
1003
|
const component = new CompactionSummaryMessageComponent(styleAttachSummaryMarkdown(message), attachMarkdownTheme());
|
|
919
1004
|
component.setExpanded(this.toolOutputExpanded);
|
|
@@ -921,35 +1006,47 @@ export class ChatView {
|
|
|
921
1006
|
break;
|
|
922
1007
|
}
|
|
923
1008
|
case 'branchSummary': {
|
|
1009
|
+
this.closeOpenGroup();
|
|
924
1010
|
this.append(new Spacer(1));
|
|
925
1011
|
const component = new BranchSummaryMessageComponent(styleAttachSummaryMarkdown(message), attachMarkdownTheme());
|
|
926
1012
|
component.setExpanded(this.toolOutputExpanded);
|
|
927
1013
|
this.append(component);
|
|
1014
|
+
this.registerCopyBlock(component, 'assistant', message.summary);
|
|
928
1015
|
break;
|
|
929
1016
|
}
|
|
930
1017
|
case 'user': {
|
|
1018
|
+
if (isTrueUserMessage(message))
|
|
1019
|
+
this.closeOpenGroup();
|
|
931
1020
|
const textContent = this.userMessageText(message);
|
|
932
1021
|
if (!textContent)
|
|
933
1022
|
break;
|
|
934
1023
|
this.pushTranscriptText(textContent);
|
|
935
1024
|
if (this.chatChildCount() > 0)
|
|
936
|
-
this.
|
|
1025
|
+
this.appendUserPart(new Spacer(1), !isTrueUserMessage(message));
|
|
937
1026
|
const skillBlock = parseSkillBlock(textContent);
|
|
938
1027
|
if (skillBlock) {
|
|
939
1028
|
const component = new SkillInvocationMessageComponent({ ...skillBlock, content: styleAttachMarkdownHeadings(skillBlock.content) }, attachMarkdownTheme());
|
|
940
1029
|
component.setExpanded(this.toolOutputExpanded);
|
|
941
|
-
this.
|
|
1030
|
+
this.appendUserPart(component, !isTrueUserMessage(message));
|
|
1031
|
+
this.registerCopyBlock(component, 'user', textContent);
|
|
942
1032
|
if (skillBlock.userMessage) {
|
|
943
|
-
|
|
1033
|
+
const userMessage = new UserMessageComponent(styleAttachMarkdownHeadings(skillBlock.userMessage), attachMarkdownTheme());
|
|
1034
|
+
this.appendUserPart(userMessage, !isTrueUserMessage(message));
|
|
1035
|
+
this.registerCopyBlock(userMessage, 'user', skillBlock.userMessage);
|
|
944
1036
|
}
|
|
945
1037
|
}
|
|
946
1038
|
else {
|
|
947
|
-
|
|
1039
|
+
const component = new UserMessageComponent(styleAttachMarkdownHeadings(textContent), attachMarkdownTheme());
|
|
1040
|
+
this.appendUserPart(component, !isTrueUserMessage(message));
|
|
1041
|
+
this.registerCopyBlock(component, 'user', textContent);
|
|
948
1042
|
}
|
|
949
1043
|
break;
|
|
950
1044
|
}
|
|
951
1045
|
case 'assistant': {
|
|
952
|
-
|
|
1046
|
+
const hasProse = assistantVisibleText(message).trim() !== '';
|
|
1047
|
+
if (hasProse)
|
|
1048
|
+
this.closeOpenGroup();
|
|
1049
|
+
this.appendAssistantMessage(message, !hasProse);
|
|
953
1050
|
break;
|
|
954
1051
|
}
|
|
955
1052
|
// toolResult is rendered inline with its tool call (handled in applySnapshot).
|
|
@@ -961,19 +1058,152 @@ export class ChatView {
|
|
|
961
1058
|
// Helpers
|
|
962
1059
|
// -------------------------------------------------------------------------
|
|
963
1060
|
/** Render an assistant reply plus its initially-hidden boundary separator. */
|
|
964
|
-
appendAssistantMessage(message) {
|
|
965
|
-
|
|
966
|
-
|
|
1061
|
+
appendAssistantMessage(message, groupMember = false) {
|
|
1062
|
+
const component = new ViewerAssistantMessageComponent(styleAttachMessageMarkdown(transformMessageDiagrams(message, this.renderWidth())), this.hideThinking, attachMarkdownTheme(), this.hiddenThinkingLabel);
|
|
1063
|
+
this.appendAssistantPart(component, groupMember);
|
|
1064
|
+
this.registerCopyBlock(component, 'assistant', assistantVisibleText(message));
|
|
1065
|
+
const separator = this.appendAssistantSeparator(groupMember);
|
|
967
1066
|
this.setAssistantSeparatorContent(separator, message);
|
|
968
1067
|
return separator;
|
|
969
1068
|
}
|
|
970
1069
|
/** Add a separator in transcript order now; it renders only after a following
|
|
971
1070
|
* tool settles in the folded view. */
|
|
972
|
-
|
|
1071
|
+
appendAssistantSeparator(groupMember = false) {
|
|
973
1072
|
const separator = new FoldedToolSeparator();
|
|
974
|
-
|
|
1073
|
+
if (groupMember)
|
|
1074
|
+
this.appendGroupMember(separator);
|
|
1075
|
+
else
|
|
1076
|
+
this.append(separator);
|
|
975
1077
|
return separator;
|
|
976
1078
|
}
|
|
1079
|
+
appendAssistantPart(component, groupMember) {
|
|
1080
|
+
if (groupMember)
|
|
1081
|
+
this.appendGroupMember(component);
|
|
1082
|
+
else
|
|
1083
|
+
this.append(component);
|
|
1084
|
+
}
|
|
1085
|
+
appendUserPart(component, groupMember) {
|
|
1086
|
+
if (groupMember)
|
|
1087
|
+
this.appendGroupMember(component);
|
|
1088
|
+
else
|
|
1089
|
+
this.append(component);
|
|
1090
|
+
}
|
|
1091
|
+
/** Only the full-fidelity trailing region gets summary groups. Condensed
|
|
1092
|
+
* history intentionally drops tool traffic, so grouping there would create
|
|
1093
|
+
* phantom summary rows and cross the live/condensed boundary. */
|
|
1094
|
+
isLiveGroupRegion() {
|
|
1095
|
+
return this.appendTarget === this.historyContainer;
|
|
1096
|
+
}
|
|
1097
|
+
ensureGroup() {
|
|
1098
|
+
if (!this.isLiveGroupRegion())
|
|
1099
|
+
return undefined;
|
|
1100
|
+
if (this.openGroup)
|
|
1101
|
+
return this.openGroup;
|
|
1102
|
+
const summaryLine = new GroupSummaryLine('', 0, 0);
|
|
1103
|
+
const group = { members: [], summaryLine, toolCount: 0, inFlight: new Set() };
|
|
1104
|
+
this.append(summaryLine);
|
|
1105
|
+
// `Text('')` still occupies a row in pi-tui. It becomes visible only when
|
|
1106
|
+
// a settled, known summary is actually selected for this group.
|
|
1107
|
+
this.historyContainer.setHidden(summaryLine, true);
|
|
1108
|
+
this.groups.push(group);
|
|
1109
|
+
this.openGroup = group;
|
|
1110
|
+
return group;
|
|
1111
|
+
}
|
|
1112
|
+
closeOpenGroup() {
|
|
1113
|
+
this.openGroup = undefined;
|
|
1114
|
+
}
|
|
1115
|
+
appendGroupMember(component) {
|
|
1116
|
+
const group = this.ensureGroup();
|
|
1117
|
+
this.append(component);
|
|
1118
|
+
if (group)
|
|
1119
|
+
group.members.push(component);
|
|
1120
|
+
}
|
|
1121
|
+
/** Used for context that may join an existing run but must not create one. */
|
|
1122
|
+
appendExistingGroupMember(component) {
|
|
1123
|
+
this.append(component);
|
|
1124
|
+
if (this.isLiveGroupRegion() && this.openGroup)
|
|
1125
|
+
this.openGroup.members.push(component);
|
|
1126
|
+
}
|
|
1127
|
+
removeGroupMember(component) {
|
|
1128
|
+
for (const group of this.groups) {
|
|
1129
|
+
const index = group.members.indexOf(component);
|
|
1130
|
+
if (index !== -1)
|
|
1131
|
+
group.members.splice(index, 1);
|
|
1132
|
+
}
|
|
1133
|
+
this.historyContainer.setHidden(component, false);
|
|
1134
|
+
}
|
|
1135
|
+
/** Add one tool as a group member, moving the assistant's boundary separator
|
|
1136
|
+
* behind the new summary row without changing the separator's identity. */
|
|
1137
|
+
appendToolComponent(name, id, args) {
|
|
1138
|
+
const trailingSeparator = this.isLiveGroupRegion() &&
|
|
1139
|
+
!this.openGroup &&
|
|
1140
|
+
this.historyContainer.children.at(-1) instanceof FoldedToolSeparator
|
|
1141
|
+
? this.historyContainer.children.at(-1)
|
|
1142
|
+
: undefined;
|
|
1143
|
+
if (trailingSeparator)
|
|
1144
|
+
this.historyContainer.removeChild(trailingSeparator);
|
|
1145
|
+
const group = this.ensureGroup();
|
|
1146
|
+
if (trailingSeparator) {
|
|
1147
|
+
this.append(trailingSeparator);
|
|
1148
|
+
group?.members.push(trailingSeparator);
|
|
1149
|
+
}
|
|
1150
|
+
const component = this.makeToolComponent(name, id, args);
|
|
1151
|
+
this.appendGroupMember(component);
|
|
1152
|
+
this.registerCopyBlock(component, 'tool', '');
|
|
1153
|
+
if (group) {
|
|
1154
|
+
group.key ??= id;
|
|
1155
|
+
group.toolCount++;
|
|
1156
|
+
group.inFlight.add(component);
|
|
1157
|
+
}
|
|
1158
|
+
return component;
|
|
1159
|
+
}
|
|
1160
|
+
settleGroupTool(component) {
|
|
1161
|
+
for (const group of this.groups)
|
|
1162
|
+
group.inFlight.delete(component);
|
|
1163
|
+
}
|
|
1164
|
+
/** An assistant starts as a non-boundary while its text is empty. The first
|
|
1165
|
+
* visible prose turns it into the boundary without recreating either pi-owned
|
|
1166
|
+
* component (their direct-child cache identities remain intact). */
|
|
1167
|
+
promoteStreamingAssistantToBoundary(message) {
|
|
1168
|
+
if (!this.streamingWasGroupMember || assistantVisibleText(message).trim() === '')
|
|
1169
|
+
return;
|
|
1170
|
+
if (this.streamingComponent)
|
|
1171
|
+
this.removeGroupMember(this.streamingComponent);
|
|
1172
|
+
if (this.streamingToolSeparator)
|
|
1173
|
+
this.removeGroupMember(this.streamingToolSeparator);
|
|
1174
|
+
this.streamingWasGroupMember = false;
|
|
1175
|
+
this.closeOpenGroup();
|
|
1176
|
+
}
|
|
1177
|
+
seedToolGroupSummaries(summaries) {
|
|
1178
|
+
this.groupSummaries.clear();
|
|
1179
|
+
for (const [key, summary] of Object.entries(normalizeToolGroupSummaryRecord(summaries))) {
|
|
1180
|
+
this.groupSummaries.set(key, summary);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/** Broker frames can arrive before or after their group's events, and snapshot
|
|
1184
|
+
* rebuilding defers them through the single-writer gate, so this stores first
|
|
1185
|
+
* and applies to every currently-rendered matching group. */
|
|
1186
|
+
applyToolGroupSummary(key, summary) {
|
|
1187
|
+
if (this.deferIfBuilding(() => this.applyToolGroupSummary(key, summary)))
|
|
1188
|
+
return;
|
|
1189
|
+
const normalized = normalizeToolGroupSummary(summary);
|
|
1190
|
+
if (key.trim() === '' || normalized === null)
|
|
1191
|
+
return;
|
|
1192
|
+
this.groupSummaries.set(key, normalized);
|
|
1193
|
+
this.applyToolDisplay();
|
|
1194
|
+
}
|
|
1195
|
+
applyGroupDisplay(group) {
|
|
1196
|
+
const summary = group.key === undefined ? undefined : this.groupSummaries.get(group.key);
|
|
1197
|
+
const collapsed = this.foldSettledTools &&
|
|
1198
|
+
this.showGroupSummaries &&
|
|
1199
|
+
group.toolCount > 0 &&
|
|
1200
|
+
group.inFlight.size === 0 &&
|
|
1201
|
+
summary !== undefined;
|
|
1202
|
+
group.summaryLine.setSummary(collapsed ? summary : undefined, group.toolCount, this.dimStyle);
|
|
1203
|
+
this.historyContainer.setHidden(group.summaryLine, !collapsed);
|
|
1204
|
+
for (const member of group.members)
|
|
1205
|
+
this.historyContainer.setHidden(member, collapsed);
|
|
1206
|
+
}
|
|
977
1207
|
/** Make each separator visible only if its immediately-following tool call is
|
|
978
1208
|
* actually folded; in-flight calls remain flush with their live output. */
|
|
979
1209
|
updateFoldedToolSeparators(inFlight = new Set(this.pendingTools.values())) {
|
|
@@ -984,7 +1214,9 @@ export class ChatView {
|
|
|
984
1214
|
continue;
|
|
985
1215
|
const followingTool = children[i + 1];
|
|
986
1216
|
separator.setFolded(this.foldSettledTools &&
|
|
1217
|
+
!this.historyContainer.hiddenChildren.has(separator) &&
|
|
987
1218
|
followingTool instanceof MinimizableToolComponent &&
|
|
1219
|
+
!this.historyContainer.hiddenChildren.has(followingTool) &&
|
|
988
1220
|
!inFlight.has(followingTool));
|
|
989
1221
|
this.historyContainer.markDirty(separator);
|
|
990
1222
|
}
|
|
@@ -1054,15 +1286,33 @@ export class ChatView {
|
|
|
1054
1286
|
this.settleBashComponent();
|
|
1055
1287
|
this.streamingComponent = undefined;
|
|
1056
1288
|
this.streamingToolSeparator = undefined;
|
|
1289
|
+
this.streamingWasGroupMember = false;
|
|
1057
1290
|
this.foldedToolSeparatorContents.clear();
|
|
1291
|
+
this.groups.length = 0;
|
|
1292
|
+
this.openGroup = undefined;
|
|
1293
|
+
this.groupSummaries.clear();
|
|
1058
1294
|
this.pendingTools.clear();
|
|
1059
1295
|
this.lastAssistantText = undefined;
|
|
1296
|
+
this.copyBlockRecords.length = 0;
|
|
1297
|
+
this.copyIndex.clear();
|
|
1060
1298
|
this.transcriptTexts.length = 0;
|
|
1061
1299
|
this.transcriptFilePaths.length = 0;
|
|
1062
1300
|
this.historyContainer.clear();
|
|
1063
1301
|
}
|
|
1064
1302
|
makeLoader(message) {
|
|
1065
|
-
|
|
1303
|
+
// Loader updates itself on an interval. Its TUI callback has to evict the
|
|
1304
|
+
// status block's cached frame before requesting the next render.
|
|
1305
|
+
let loader;
|
|
1306
|
+
const statusTui = {
|
|
1307
|
+
requestRender: (force) => {
|
|
1308
|
+
if (loader)
|
|
1309
|
+
this.statusContainer.markDirty(loader);
|
|
1310
|
+
this.tui.requestRender(force);
|
|
1311
|
+
},
|
|
1312
|
+
terminal: this.tui.terminal,
|
|
1313
|
+
};
|
|
1314
|
+
loader = new Loader(statusTui, this.spinnerStyle, this.dimStyle, message);
|
|
1315
|
+
return loader;
|
|
1066
1316
|
}
|
|
1067
1317
|
/** Let a self-updating history child invalidate only its own cached lines. */
|
|
1068
1318
|
componentTui(component) {
|
|
@@ -1092,10 +1342,12 @@ export class ChatView {
|
|
|
1092
1342
|
this.statusContainer.addChild(loader);
|
|
1093
1343
|
}
|
|
1094
1344
|
showStatus(message) {
|
|
1345
|
+
this.closeOpenGroup();
|
|
1095
1346
|
this.append(new Spacer(1));
|
|
1096
1347
|
this.append(new Text(this.dimStyle(message), 1, 0));
|
|
1097
1348
|
}
|
|
1098
1349
|
showError(message) {
|
|
1350
|
+
this.closeOpenGroup();
|
|
1099
1351
|
this.append(new Spacer(1));
|
|
1100
1352
|
this.append(new Text(this.errorStyle(message), 1, 0));
|
|
1101
1353
|
}
|
|
@@ -1131,6 +1383,98 @@ export class ChatView {
|
|
|
1131
1383
|
const t = this.lastAssistantText?.trim();
|
|
1132
1384
|
return t ? t : undefined;
|
|
1133
1385
|
}
|
|
1386
|
+
/** Copyable source text for a transcript block, if it has any. */
|
|
1387
|
+
blockText(block) {
|
|
1388
|
+
const index = this.copyIndex.get(block);
|
|
1389
|
+
return index === undefined ? undefined : this.copyBlockRecords[index]?.text;
|
|
1390
|
+
}
|
|
1391
|
+
/** The canonical ordered copy record, oldest → newest. */
|
|
1392
|
+
copyBlocks() {
|
|
1393
|
+
return this.copyBlockRecords;
|
|
1394
|
+
}
|
|
1395
|
+
/** Newest tool call's result text (`/copy-tool`). */
|
|
1396
|
+
getLastToolOutputText() {
|
|
1397
|
+
for (let i = this.copyBlockRecords.length - 1; i >= 0; i--) {
|
|
1398
|
+
const copyBlock = this.copyBlockRecords[i];
|
|
1399
|
+
if (copyBlock.kind !== 'tool')
|
|
1400
|
+
continue;
|
|
1401
|
+
return copyBlock.text.trim() === '' ? undefined : copyBlock.text;
|
|
1402
|
+
}
|
|
1403
|
+
return undefined;
|
|
1404
|
+
}
|
|
1405
|
+
/** Every copyable block's text, oldest → newest, joined by blank lines. */
|
|
1406
|
+
getTranscriptPlainText() {
|
|
1407
|
+
return this.copyBlockRecords
|
|
1408
|
+
.map((copyBlock) => copyBlock.text)
|
|
1409
|
+
.filter((text) => text.trim() !== '')
|
|
1410
|
+
.join('\n\n');
|
|
1411
|
+
}
|
|
1412
|
+
registerCopyBlock(block, kind, text) {
|
|
1413
|
+
const condensedCopyBlocks = this.condensedCopyBlocks.get(this.appendTarget);
|
|
1414
|
+
if (condensedCopyBlocks) {
|
|
1415
|
+
const index = condensedCopyBlocks.findIndex((copyBlock) => copyBlock.block === block);
|
|
1416
|
+
if (index === -1)
|
|
1417
|
+
condensedCopyBlocks.push({ block, kind, text });
|
|
1418
|
+
else
|
|
1419
|
+
condensedCopyBlocks[index].text = text;
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const index = this.copyIndex.get(block);
|
|
1423
|
+
if (index === undefined) {
|
|
1424
|
+
this.copyIndex.set(block, this.copyBlockRecords.length);
|
|
1425
|
+
this.copyBlockRecords.push({ block, kind, text });
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
this.copyBlockRecords[index].text = text;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
unregisterCopyBlock(block) {
|
|
1432
|
+
const condensedCopyBlocks = this.condensedCopyBlocks.get(this.appendTarget);
|
|
1433
|
+
if (condensedCopyBlocks) {
|
|
1434
|
+
const index = condensedCopyBlocks.findIndex((copyBlock) => copyBlock.block === block);
|
|
1435
|
+
if (index !== -1)
|
|
1436
|
+
condensedCopyBlocks.splice(index, 1);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const index = this.copyIndex.get(block);
|
|
1440
|
+
if (index === undefined)
|
|
1441
|
+
return;
|
|
1442
|
+
this.copyBlockRecords.splice(index, 1);
|
|
1443
|
+
this.copyIndex.delete(block);
|
|
1444
|
+
for (let i = index; i < this.copyBlockRecords.length; i++) {
|
|
1445
|
+
this.copyIndex.set(this.copyBlockRecords[i].block, i);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
appendCondensedBlock({ component, copyBlocks }) {
|
|
1449
|
+
this.append(component);
|
|
1450
|
+
this.registerCopyBlock(component, 'condensed', copyBlocks.map((copyBlock) => copyBlock.text).filter((text) => text.trim() !== '').join('\n\n'));
|
|
1451
|
+
}
|
|
1452
|
+
/** Flatten arbitrary tool result payloads into the visible text copy target. */
|
|
1453
|
+
toolResultText(result) {
|
|
1454
|
+
if (typeof result === 'string')
|
|
1455
|
+
return result;
|
|
1456
|
+
if (typeof result === 'object' && result !== null && 'content' in result) {
|
|
1457
|
+
const content = result.content;
|
|
1458
|
+
if (typeof content === 'string')
|
|
1459
|
+
return content;
|
|
1460
|
+
if (Array.isArray(content)) {
|
|
1461
|
+
return content
|
|
1462
|
+
.filter((part) => typeof part === 'object' &&
|
|
1463
|
+
part !== null &&
|
|
1464
|
+
part.type === 'text' &&
|
|
1465
|
+
typeof part.text === 'string')
|
|
1466
|
+
.map((part) => part.text)
|
|
1467
|
+
.join('');
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
try {
|
|
1471
|
+
const text = JSON.stringify(result);
|
|
1472
|
+
return typeof text === 'string' ? text : '';
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
return '';
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1134
1478
|
pushTranscriptText(text) {
|
|
1135
1479
|
const trimmed = text.trim();
|
|
1136
1480
|
if (trimmed === '')
|