@oh-my-pi/pi-coding-agent 16.0.10 → 16.0.11
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 +35 -0
- package/dist/cli.js +3208 -3199
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +52 -0
- package/dist/types/commit/agentic/agent.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +0 -4
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +6 -1
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/registry/agent-registry.d.ts +10 -3
- package/dist/types/session/compact-modes.d.ts +60 -0
- package/dist/types/session/streaming-output.d.ts +0 -2
- package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
- package/package.json +12 -12
- package/src/advisor/index.ts +1 -0
- package/src/advisor/transcript-recorder.ts +136 -0
- package/src/cli/stats-cli.ts +2 -11
- package/src/collab/host.ts +25 -13
- package/src/commit/agentic/agent.ts +2 -1
- package/src/commit/agentic/tools/git-file-diff.ts +2 -2
- package/src/commit/changelog/index.ts +1 -1
- package/src/commit/map-reduce/map-phase.ts +1 -1
- package/src/commit/map-reduce/utils.ts +1 -1
- package/src/config/settings-schema.ts +0 -5
- package/src/config/settings.ts +0 -6
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/eval/js/tool-bridge.ts +3 -2
- package/src/eval/py/prelude.py +3 -2
- package/src/export/html/tool-views.generated.js +28 -28
- package/src/extensibility/extensions/types.ts +7 -0
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/history-protocol.ts +8 -3
- package/src/irc/bus.ts +8 -0
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +4 -1
- package/src/modes/acp/acp-agent.ts +63 -0
- package/src/modes/components/agent-hub.ts +97 -920
- package/src/modes/components/agent-transcript-viewer.ts +461 -0
- package/src/modes/components/chat-transcript-builder.ts +462 -0
- package/src/modes/components/diff.ts +12 -35
- package/src/modes/controllers/command-controller.ts +12 -2
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +8 -1
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +4 -2
- package/src/modes/types.ts +2 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/registry/agent-registry.ts +13 -4
- package/src/sdk.ts +1 -1
- package/src/session/agent-session.ts +92 -3
- package/src/session/compact-modes.ts +105 -0
- package/src/session/session-dump-format.ts +1 -1
- package/src/session/session-history-format.ts +1 -1
- package/src/session/streaming-output.ts +5 -5
- package/src/slash-commands/builtin-registry.ts +16 -4
- package/src/task/executor.ts +1 -1
- package/src/task/output-manager.ts +5 -0
- package/src/tools/__tests__/json-tree.test.ts +35 -0
- package/src/tools/approval.ts +1 -1
- package/src/tools/bash.ts +0 -1
- package/src/tools/browser.ts +0 -1
- package/src/tools/eval.ts +1 -1
- package/src/tools/gh.ts +1 -1
- package/src/tools/irc.ts +1 -1
- package/src/tools/json-tree.ts +22 -5
- package/src/tools/read.ts +5 -6
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/github.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/metacpan.ts +2 -2
- package/src/web/scrapers/nvd.ts +2 -2
- package/src/web/scrapers/ollama.ts +1 -1
- package/src/web/scrapers/opencorporates.ts +1 -1
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/repology.ts +1 -1
- package/src/web/scrapers/sourcegraph.ts +1 -1
- package/src/web/scrapers/terraform.ts +6 -6
- package/src/web/scrapers/wikidata.ts +2 -2
- package/src/workspace-tree.ts +1 -1
|
@@ -15,63 +15,26 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
|
-
import type {
|
|
19
|
-
import type
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import type { AdvisorMessageDetails } from "../../advisor";
|
|
23
|
-
import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
|
|
18
|
+
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
19
|
+
import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui";
|
|
20
|
+
import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { ADVISOR_TRANSCRIPT_FILENAME } from "../../advisor";
|
|
24
22
|
import type { KeyId } from "../../config/keybindings";
|
|
25
|
-
import { settings } from "../../config/settings";
|
|
26
23
|
import type { MessageRenderer } from "../../extensibility/extensions/types";
|
|
27
24
|
import { IrcBus } from "../../irc/bus";
|
|
28
25
|
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
|
29
26
|
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
30
|
-
import
|
|
31
|
-
import {
|
|
32
|
-
BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
|
|
33
|
-
type CustomMessage,
|
|
34
|
-
isSilentAbort,
|
|
35
|
-
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
|
|
36
|
-
resolveAbortLabel,
|
|
37
|
-
SKILL_PROMPT_MESSAGE_TYPE,
|
|
38
|
-
type SkillPromptDetails,
|
|
39
|
-
USER_INTERRUPT_LABEL,
|
|
40
|
-
} from "../../session/messages";
|
|
41
|
-
import type { SessionMessageEntry } from "../../session/session-entries";
|
|
42
|
-
import { parseSessionEntries } from "../../session/session-loader";
|
|
43
|
-
import { createIrcMessageCard } from "../../tools/irc";
|
|
27
|
+
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
|
44
28
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
45
|
-
import { canonicalizeMessage } from "../../utils/thinking-display";
|
|
46
29
|
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
|
47
|
-
import {
|
|
30
|
+
import { theme } from "../theme/theme";
|
|
48
31
|
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
|
|
49
|
-
import {
|
|
50
|
-
import { AssistantMessageComponent } from "./assistant-message";
|
|
51
|
-
import { createBackgroundTanDispatchBlock } from "./background-tan-message";
|
|
52
|
-
import { BashExecutionComponent } from "./bash-execution";
|
|
53
|
-
import { BranchSummaryMessageComponent } from "./branch-summary-message";
|
|
54
|
-
import { CollabPromptMessageComponent } from "./collab-prompt-message";
|
|
55
|
-
import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message";
|
|
56
|
-
import { CustomMessageComponent } from "./custom-message";
|
|
32
|
+
import { AgentTranscriptViewer } from "./agent-transcript-viewer";
|
|
57
33
|
import { DynamicBorder } from "./dynamic-border";
|
|
58
|
-
import { EvalExecutionComponent } from "./eval-execution";
|
|
59
|
-
import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
|
|
60
|
-
import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
|
|
61
|
-
import { SkillMessageComponent } from "./skill-message";
|
|
62
|
-
import { formatContextUsage } from "./status-line/context-thresholds";
|
|
63
|
-
import { ToolExecutionComponent } from "./tool-execution";
|
|
64
|
-
import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
|
|
65
|
-
import { createUsageRowBlock } from "./usage-row";
|
|
66
|
-
import { UserMessageComponent } from "./user-message";
|
|
67
34
|
|
|
68
|
-
/** Lines per page for PageUp/PageDown */
|
|
69
|
-
const PAGE_SIZE = 15;
|
|
70
35
|
/** Refresh cadence for the relative-time column */
|
|
71
36
|
const AGE_TICK_MS = 5_000;
|
|
72
|
-
/**
|
|
73
|
-
const CHAT_REFRESH_DEBOUNCE_MS = 80;
|
|
74
|
-
/** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */
|
|
37
|
+
/** Double-tap window for the table's left-left "close hub" gesture. */
|
|
75
38
|
const LEFT_TAP_WINDOW_MS = 500;
|
|
76
39
|
|
|
77
40
|
/** Compute the max content width for the current terminal, accounting for chrome. */
|
|
@@ -120,8 +83,33 @@ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string,
|
|
|
120
83
|
}
|
|
121
84
|
for (const entry of entries) {
|
|
122
85
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
|
|
123
|
-
const id = entry.name.slice(0, -6);
|
|
124
86
|
const sessionFile = path.join(dir, entry.name);
|
|
87
|
+
// The advisor transcript is observability-only: register it as a non-peer
|
|
88
|
+
// `advisor` kind under its owning session so the Hub can show its read-only
|
|
89
|
+
// transcript, but it never joins agent-facing rosters and is not revivable.
|
|
90
|
+
if (entry.name === ADVISOR_TRANSCRIPT_FILENAME) {
|
|
91
|
+
const owner = parentId ?? MAIN_AGENT_ID;
|
|
92
|
+
const advisorId = `${owner}/advisor`;
|
|
93
|
+
const existing = registry.get(advisorId);
|
|
94
|
+
// Never clobber a non-advisor ref that happens to share this id (a freak
|
|
95
|
+
// user task literally named `<owner>/advisor`): leave it, skip the advisor.
|
|
96
|
+
if (existing && existing.kind !== "advisor") continue;
|
|
97
|
+
if (existing?.sessionFile !== sessionFile) {
|
|
98
|
+
// The id is reused across `/new`; refresh it to the current session's file.
|
|
99
|
+
if (existing) registry.unregister(advisorId);
|
|
100
|
+
registry.register({
|
|
101
|
+
id: advisorId,
|
|
102
|
+
displayName: "advisor",
|
|
103
|
+
kind: "advisor",
|
|
104
|
+
parentId: owner,
|
|
105
|
+
session: null,
|
|
106
|
+
sessionFile,
|
|
107
|
+
status: "parked",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const id = entry.name.slice(0, -6);
|
|
125
113
|
if (!registry.get(id)) {
|
|
126
114
|
registry.register({
|
|
127
115
|
id,
|
|
@@ -190,29 +178,17 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
190
178
|
#unsubscribers: Array<() => void> = [];
|
|
191
179
|
#ageTimer: NodeJS.Timeout | undefined;
|
|
192
180
|
#remote: AgentHubRemote | undefined;
|
|
193
|
-
#remoteFetchInFlight = false;
|
|
194
|
-
/** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */
|
|
195
|
-
#remoteFetchToken = 0;
|
|
196
|
-
#remoteTranscriptUnavailable = false;
|
|
197
181
|
|
|
198
182
|
// Table state
|
|
199
|
-
#view: "table" | "chat" = "table";
|
|
200
183
|
#rows: AgentRef[] = [];
|
|
201
184
|
#selectedRow = 0;
|
|
202
185
|
#notice: string | undefined;
|
|
203
186
|
/** Captured row order from the first refresh; keeps the hub stable while open. */
|
|
204
187
|
#rowOrder: Map<string, number> | undefined;
|
|
188
|
+
/** Double-tap window state for the table's left-left "close hub" gesture. */
|
|
189
|
+
#lastLeftTap = 0;
|
|
205
190
|
|
|
206
|
-
//
|
|
207
|
-
#chatAgentId: string | undefined;
|
|
208
|
-
#editor: Editor;
|
|
209
|
-
#sessionUnsubscribe: (() => void) | undefined;
|
|
210
|
-
#attachedSession: AgentSession | undefined;
|
|
211
|
-
#chatRefreshTimer: NodeJS.Timeout | undefined;
|
|
212
|
-
#transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
|
|
213
|
-
|
|
214
|
-
// Chat transcript: the same component renderers as the main session
|
|
215
|
-
// transcript, assembled incrementally from the persisted JSONL entries.
|
|
191
|
+
// Transcript-viewer launch deps (passed through to AgentTranscriptViewer).
|
|
216
192
|
#ui: TUI;
|
|
217
193
|
#getTool: ((name: string) => AgentTool | undefined) | undefined;
|
|
218
194
|
#getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
|
|
@@ -220,25 +196,10 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
220
196
|
#hideThinkingBlock: (() => boolean) | undefined;
|
|
221
197
|
#expandKeys: KeyId[];
|
|
222
198
|
#focusAgent: ((id: string) => Promise<void>) | undefined;
|
|
223
|
-
#chatLog = new TranscriptContainer();
|
|
224
|
-
#chatEntriesRef: SessionMessageEntry[] | undefined;
|
|
225
|
-
#chatBuiltCount = 0;
|
|
226
|
-
#chatPendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
|
|
227
|
-
#chatReadArgs = new Map<string, Record<string, unknown>>();
|
|
228
|
-
#chatReadGroup: ReadToolGroupComponent | null = null;
|
|
229
|
-
#pendingUsage: Usage | undefined;
|
|
230
|
-
#chatWaitingPoll: ToolExecutionComponent | null = null;
|
|
231
|
-
#chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
|
232
|
-
#chatExpanded = false;
|
|
233
|
-
#chatPlaceholder: string | undefined;
|
|
234
199
|
|
|
235
|
-
//
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#viewportHeight = 20;
|
|
239
|
-
#wasAtBottom = true;
|
|
240
|
-
#viewerHeaderLines: string[] = [];
|
|
241
|
-
#lastLeftTap = 0;
|
|
200
|
+
// Fullscreen transcript overlay opened by openChat(), if any.
|
|
201
|
+
#transcriptOverlay: OverlayHandle | undefined;
|
|
202
|
+
#transcriptViewer: AgentTranscriptViewer | undefined;
|
|
242
203
|
|
|
243
204
|
constructor(deps: AgentHubDeps) {
|
|
244
205
|
super();
|
|
@@ -265,10 +226,6 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
265
226
|
this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
|
|
266
227
|
this.#focusAgent = deps.focusAgent;
|
|
267
228
|
|
|
268
|
-
this.#editor = new Editor(getEditorTheme());
|
|
269
|
-
this.#editor.setMaxHeight(4);
|
|
270
|
-
this.#editor.onSubmit = text => this.#submitChatMessage(text);
|
|
271
|
-
|
|
272
229
|
this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
|
|
273
230
|
this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
|
|
274
231
|
this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
|
|
@@ -294,17 +251,11 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
294
251
|
clearInterval(this.#ageTimer);
|
|
295
252
|
this.#ageTimer = undefined;
|
|
296
253
|
}
|
|
297
|
-
|
|
298
|
-
clearTimeout(this.#chatRefreshTimer);
|
|
299
|
-
this.#chatRefreshTimer = undefined;
|
|
300
|
-
}
|
|
301
|
-
this.#detachLiveSession();
|
|
302
|
-
this.#resetChatLog();
|
|
254
|
+
this.#closeTranscriptOverlay();
|
|
303
255
|
}
|
|
304
256
|
|
|
305
257
|
override render(width: number): readonly string[] {
|
|
306
|
-
|
|
307
|
-
return lines.map(line => clampHubLine(line, width));
|
|
258
|
+
return this.#renderTable(width).map(line => clampHubLine(line, width));
|
|
308
259
|
}
|
|
309
260
|
|
|
310
261
|
handleInput(keyData: string): void {
|
|
@@ -315,30 +266,53 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
315
266
|
return;
|
|
316
267
|
}
|
|
317
268
|
}
|
|
318
|
-
|
|
319
|
-
this.#handleTableInput(keyData);
|
|
320
|
-
} else {
|
|
321
|
-
this.#handleChatInput(keyData);
|
|
322
|
-
}
|
|
269
|
+
this.#handleTableInput(keyData);
|
|
323
270
|
}
|
|
324
271
|
|
|
325
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Open the fullscreen transcript viewer for an agent id (public for table Enter
|
|
274
|
+
* and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
|
|
275
|
+
* owns the alternate screen; the hub table stays mounted underneath and is
|
|
276
|
+
* restored when the viewer closes. No-op without a real TUI (render-only test stub).
|
|
277
|
+
*/
|
|
326
278
|
openChat(id: string): void {
|
|
327
279
|
if (!this.#registry.get(id)) return;
|
|
328
|
-
this.#
|
|
329
|
-
this.#
|
|
280
|
+
if (typeof this.#ui.showOverlay !== "function") return;
|
|
281
|
+
this.#closeTranscriptOverlay();
|
|
330
282
|
this.#notice = undefined;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
283
|
+
const viewer = new AgentTranscriptViewer({
|
|
284
|
+
agentId: id,
|
|
285
|
+
registry: this.#registry,
|
|
286
|
+
remote: this.#remote,
|
|
287
|
+
observers: this.#observers,
|
|
288
|
+
lifecycle: this.#remote ? undefined : this.#lifecycle,
|
|
289
|
+
ui: this.#ui,
|
|
290
|
+
getTool: this.#getTool,
|
|
291
|
+
getMessageRenderer: this.#getMessageRenderer,
|
|
292
|
+
cwd: this.#cwd,
|
|
293
|
+
hideThinkingBlock: this.#hideThinkingBlock,
|
|
294
|
+
expandKeys: this.#expandKeys,
|
|
295
|
+
hubKeys: this.#hubKeys,
|
|
296
|
+
requestRender: this.#requestRender,
|
|
297
|
+
onClose: () => this.#closeTranscriptOverlay(),
|
|
298
|
+
onHubClose: () => {
|
|
299
|
+
this.#closeTranscriptOverlay();
|
|
300
|
+
this.#onDone();
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
this.#transcriptViewer = viewer;
|
|
304
|
+
this.#transcriptOverlay = this.#ui.showOverlay(viewer, { width: "100%", margin: 0, fullscreen: true });
|
|
305
|
+
this.#ui.setFocus(viewer);
|
|
306
|
+
this.#requestRender();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Close and dispose the transcript overlay, restoring focus to the hub table. */
|
|
310
|
+
#closeTranscriptOverlay(): void {
|
|
311
|
+
this.#transcriptOverlay?.hide();
|
|
312
|
+
this.#transcriptOverlay = undefined;
|
|
313
|
+
this.#transcriptViewer?.dispose();
|
|
314
|
+
this.#transcriptViewer = undefined;
|
|
315
|
+
if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this);
|
|
342
316
|
this.#requestRender();
|
|
343
317
|
}
|
|
344
318
|
|
|
@@ -348,12 +322,6 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
348
322
|
|
|
349
323
|
#onDataChange(): void {
|
|
350
324
|
this.#refreshRows();
|
|
351
|
-
if (this.#view === "chat") {
|
|
352
|
-
// A revive/park swaps the live session out from under the chat view.
|
|
353
|
-
this.#attachLiveSession();
|
|
354
|
-
this.#scheduleChatRefresh();
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
325
|
this.#requestRender();
|
|
358
326
|
}
|
|
359
327
|
|
|
@@ -389,39 +357,6 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
389
357
|
this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
|
|
390
358
|
}
|
|
391
359
|
|
|
392
|
-
/** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
|
|
393
|
-
#attachLiveSession(): void {
|
|
394
|
-
// Remote refs carry no live session handle; refreshes come from observer onChange.
|
|
395
|
-
if (this.#remote) return;
|
|
396
|
-
const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
|
|
397
|
-
if (session === this.#attachedSession) return;
|
|
398
|
-
this.#detachLiveSession();
|
|
399
|
-
if (!session) return;
|
|
400
|
-
this.#attachedSession = session;
|
|
401
|
-
this.#sessionUnsubscribe = session.subscribe(event => {
|
|
402
|
-
if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
|
|
403
|
-
this.#scheduleChatRefresh();
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
#detachLiveSession(): void {
|
|
409
|
-
this.#sessionUnsubscribe?.();
|
|
410
|
-
this.#sessionUnsubscribe = undefined;
|
|
411
|
-
this.#attachedSession = undefined;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
#scheduleChatRefresh(): void {
|
|
415
|
-
if (this.#chatRefreshTimer) return;
|
|
416
|
-
this.#chatRefreshTimer = setTimeout(() => {
|
|
417
|
-
this.#chatRefreshTimer = undefined;
|
|
418
|
-
if (this.#view !== "chat") return;
|
|
419
|
-
this.#rebuildChatContent();
|
|
420
|
-
this.#requestRender();
|
|
421
|
-
}, CHAT_REFRESH_DEBOUNCE_MS);
|
|
422
|
-
this.#chatRefreshTimer.unref?.();
|
|
423
|
-
}
|
|
424
|
-
|
|
425
360
|
#observableFor(id: string): ObservableSession | undefined {
|
|
426
361
|
return this.#observers.getSessions().find(s => s.id === id);
|
|
427
362
|
}
|
|
@@ -553,7 +488,9 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
553
488
|
#activateAgent(ref: AgentRef): void {
|
|
554
489
|
this.#notice = undefined;
|
|
555
490
|
const focusAgent = this.#focusAgent;
|
|
556
|
-
|
|
491
|
+
// Advisor refs are read-only transcripts with no live/ revivable session;
|
|
492
|
+
// open the in-hub chat view (file-backed) instead of trying to focus one.
|
|
493
|
+
if (ref.kind === "advisor" || this.#remote || !focusAgent) {
|
|
557
494
|
this.openChat(ref.id);
|
|
558
495
|
return;
|
|
559
496
|
}
|
|
@@ -571,6 +508,11 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
571
508
|
#reviveSelected(): void {
|
|
572
509
|
const ref = this.#rows[this.#selectedRow];
|
|
573
510
|
if (!ref) return;
|
|
511
|
+
if (ref.kind === "advisor") {
|
|
512
|
+
this.#notice = `"${ref.id}" is a read-only advisor transcript — nothing to revive.`;
|
|
513
|
+
this.#requestRender();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
574
516
|
if (ref.status !== "parked") {
|
|
575
517
|
this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
|
|
576
518
|
this.#requestRender();
|
|
@@ -595,6 +537,11 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
595
537
|
#killSelected(): void {
|
|
596
538
|
const ref = this.#rows[this.#selectedRow];
|
|
597
539
|
if (!ref) return;
|
|
540
|
+
if (ref.kind === "advisor") {
|
|
541
|
+
this.#notice = `"${ref.id}" is a read-only advisor transcript — cannot be killed.`;
|
|
542
|
+
this.#requestRender();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
598
545
|
this.#notice = undefined;
|
|
599
546
|
if (this.#remote) {
|
|
600
547
|
this.#remote.kill(ref.id);
|
|
@@ -616,774 +563,4 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
616
563
|
this.#requestRender();
|
|
617
564
|
})();
|
|
618
565
|
}
|
|
619
|
-
|
|
620
|
-
// ========================================================================
|
|
621
|
-
// Chat view
|
|
622
|
-
// ========================================================================
|
|
623
|
-
|
|
624
|
-
#renderChat(width: number): string[] {
|
|
625
|
-
const termHeight = process.stdout.rows || 40;
|
|
626
|
-
const innerWidth = Math.max(20, width - 2);
|
|
627
|
-
const editorLines = this.#editor.render(innerWidth);
|
|
628
|
-
const noticeLine = this.#notice
|
|
629
|
-
? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`
|
|
630
|
-
: undefined;
|
|
631
|
-
const footerLines = this.#buildChatFooterLines();
|
|
632
|
-
|
|
633
|
-
// Header: border + headerLines + border; footer: notice? + editor + footer + border
|
|
634
|
-
const headerChrome = this.#viewerHeaderLines.length + 2;
|
|
635
|
-
const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
|
|
636
|
-
this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
|
|
637
|
-
|
|
638
|
-
const contentLines: readonly string[] = this.#chatPlaceholder
|
|
639
|
-
? [theme.fg("dim", this.#chatPlaceholder)]
|
|
640
|
-
: this.#chatLog.render(innerWidth);
|
|
641
|
-
|
|
642
|
-
const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight);
|
|
643
|
-
this.#lastMaxScroll = maxScroll;
|
|
644
|
-
if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
|
|
645
|
-
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
|
|
646
|
-
|
|
647
|
-
const lines: string[] = [];
|
|
648
|
-
lines.push(...new DynamicBorder().render(width));
|
|
649
|
-
for (const headerLine of this.#viewerHeaderLines) {
|
|
650
|
-
lines.push(` ${headerLine}`);
|
|
651
|
-
}
|
|
652
|
-
lines.push(...new DynamicBorder().render(width));
|
|
653
|
-
|
|
654
|
-
const scrollView = new ScrollView(
|
|
655
|
-
contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
|
|
656
|
-
{
|
|
657
|
-
height: this.#viewportHeight,
|
|
658
|
-
scrollbar: "auto",
|
|
659
|
-
totalRows: contentLines.length,
|
|
660
|
-
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
|
|
661
|
-
},
|
|
662
|
-
);
|
|
663
|
-
scrollView.setScrollOffset(this.#scrollOffset);
|
|
664
|
-
for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
|
|
665
|
-
|
|
666
|
-
if (noticeLine) lines.push(noticeLine);
|
|
667
|
-
for (const editorLine of editorLines) lines.push(` ${editorLine}`);
|
|
668
|
-
lines.push(...footerLines);
|
|
669
|
-
lines.push(...new DynamicBorder().render(width));
|
|
670
|
-
return lines;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
#buildChatFooterLines(): string[] {
|
|
674
|
-
const lines: string[] = [];
|
|
675
|
-
const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
|
|
676
|
-
const statsLine = this.#buildStatsLine(observed);
|
|
677
|
-
if (statsLine) lines.push(` ${statsLine}`);
|
|
678
|
-
lines.push(
|
|
679
|
-
` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`,
|
|
680
|
-
);
|
|
681
|
-
return lines;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
#buildStatsLine(observed: ObservableSession | undefined): string {
|
|
685
|
-
const progress = observed?.progress;
|
|
686
|
-
if (!progress) return "";
|
|
687
|
-
const stats: string[] = [];
|
|
688
|
-
// Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
|
|
689
|
-
if (progress.contextTokens && progress.contextTokens > 0) {
|
|
690
|
-
const ctx =
|
|
691
|
-
progress.contextWindow && progress.contextWindow > 0
|
|
692
|
-
? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
|
|
693
|
-
: `${formatNumber(progress.contextTokens)}`;
|
|
694
|
-
stats.push(ctx);
|
|
695
|
-
}
|
|
696
|
-
if (progress.durationMs > 0) {
|
|
697
|
-
stats.push(formatDuration(progress.durationMs));
|
|
698
|
-
}
|
|
699
|
-
const parts: string[] = [];
|
|
700
|
-
if (stats.length > 0 || progress.toolCount > 0) {
|
|
701
|
-
const toolCountStat =
|
|
702
|
-
progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined;
|
|
703
|
-
const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment));
|
|
704
|
-
parts.push(theme.fg("dim", statSegments.join(theme.sep.dot)));
|
|
705
|
-
}
|
|
706
|
-
if (progress.cost > 0) {
|
|
707
|
-
parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
|
|
708
|
-
}
|
|
709
|
-
return parts.join(theme.sep.dot);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/** Rebuild the chat header and sync transcript components from new entries */
|
|
713
|
-
#rebuildChatContent(): void {
|
|
714
|
-
const id = this.#chatAgentId;
|
|
715
|
-
const ref = id ? this.#registry.get(id) : undefined;
|
|
716
|
-
|
|
717
|
-
// Load transcript first so model info is available for the header
|
|
718
|
-
let messageEntries: SessionMessageEntry[] | null = null;
|
|
719
|
-
if (this.#remote) {
|
|
720
|
-
if (id) this.#fetchRemoteTranscript(id);
|
|
721
|
-
messageEntries = this.#transcriptCache?.entries ?? [];
|
|
722
|
-
} else if (ref?.sessionFile) {
|
|
723
|
-
messageEntries = this.#loadTranscript(ref.sessionFile);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
this.#viewerHeaderLines = [];
|
|
727
|
-
this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`));
|
|
728
|
-
if (ref) {
|
|
729
|
-
const observed = this.#observableFor(ref.id);
|
|
730
|
-
const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model;
|
|
731
|
-
const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`);
|
|
732
|
-
const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : "";
|
|
733
|
-
this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (!ref) {
|
|
737
|
-
this.#chatPlaceholder = "Agent no longer registered.";
|
|
738
|
-
} else if (!this.#remote && !ref.sessionFile) {
|
|
739
|
-
this.#chatPlaceholder = "No session file available yet.";
|
|
740
|
-
} else if (!messageEntries) {
|
|
741
|
-
this.#chatPlaceholder = "Unable to read session file.";
|
|
742
|
-
} else if (messageEntries.length === 0) {
|
|
743
|
-
if (this.#remote && this.#remoteTranscriptUnavailable) {
|
|
744
|
-
this.#chatPlaceholder = "Transcript lives on the host — not available.";
|
|
745
|
-
} else if (this.#remote && !this.#transcriptCache) {
|
|
746
|
-
this.#chatPlaceholder = "Loading transcript from host…";
|
|
747
|
-
} else {
|
|
748
|
-
this.#chatPlaceholder = "No messages yet.";
|
|
749
|
-
}
|
|
750
|
-
} else {
|
|
751
|
-
this.#chatPlaceholder = undefined;
|
|
752
|
-
this.#syncChatComponents(messageEntries);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
#handleChatInput(keyData: string): void {
|
|
757
|
-
const editorEmpty = this.#editor.getText().trim() === "";
|
|
758
|
-
|
|
759
|
-
if (matchesKey(keyData, "escape")) {
|
|
760
|
-
if (!editorEmpty) {
|
|
761
|
-
this.#editor.setText("");
|
|
762
|
-
this.#requestRender();
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
this.#closeChat();
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// Tool output expansion mirrors the main transcript's app.tools.expand toggle.
|
|
770
|
-
for (const key of this.#expandKeys) {
|
|
771
|
-
if (matchesKey(keyData, key)) {
|
|
772
|
-
this.#chatExpanded = !this.#chatExpanded;
|
|
773
|
-
for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded);
|
|
774
|
-
this.#requestRender();
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
// Double-tap left on an empty editor hops to the parent session —
|
|
780
|
-
// the inverse of the main editor's double-left opening the hub.
|
|
781
|
-
if (editorEmpty && matchesKey(keyData, "left")) {
|
|
782
|
-
const now = Date.now();
|
|
783
|
-
if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
|
|
784
|
-
this.#lastLeftTap = 0;
|
|
785
|
-
this.#openParent();
|
|
786
|
-
} else {
|
|
787
|
-
this.#lastLeftTap = now;
|
|
788
|
-
}
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// Scrolling works while the input is empty; once the user starts
|
|
793
|
-
// typing, the editor owns every key.
|
|
794
|
-
if (editorEmpty && this.#handleViewerNavigation(keyData)) {
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
this.#editor.handleInput(keyData);
|
|
799
|
-
this.#requestRender();
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/** Open the chat for the agent's parent, or close the hub when the parent is the main session. */
|
|
803
|
-
#openParent(): void {
|
|
804
|
-
const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined;
|
|
805
|
-
const parentId = ref?.parentId;
|
|
806
|
-
if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) {
|
|
807
|
-
this.openChat(parentId);
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
this.#onDone();
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
#closeChat(): void {
|
|
814
|
-
this.#view = "table";
|
|
815
|
-
this.#chatAgentId = undefined;
|
|
816
|
-
this.#notice = undefined;
|
|
817
|
-
this.#detachLiveSession();
|
|
818
|
-
this.#resetChatLog();
|
|
819
|
-
this.#refreshRows();
|
|
820
|
-
this.#requestRender();
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
#submitChatMessage(text: string): void {
|
|
824
|
-
const id = this.#chatAgentId;
|
|
825
|
-
const trimmed = text.trim();
|
|
826
|
-
if (!id || !trimmed) return;
|
|
827
|
-
this.#editor.setText("");
|
|
828
|
-
this.#notice = undefined;
|
|
829
|
-
if (this.#remote) {
|
|
830
|
-
this.#remote.chat(id, trimmed);
|
|
831
|
-
this.#scheduleChatRefresh();
|
|
832
|
-
this.#requestRender();
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
void (async () => {
|
|
836
|
-
try {
|
|
837
|
-
// Revives a parked agent; returns the live session for running/idle.
|
|
838
|
-
const session = await this.#lifecycle().ensureLive(id);
|
|
839
|
-
this.#attachLiveSession();
|
|
840
|
-
// Steers a mid-turn agent; sends a normal prompt to an idle one.
|
|
841
|
-
await session.prompt(trimmed, { streamingBehavior: "steer" });
|
|
842
|
-
} catch (error) {
|
|
843
|
-
this.#notice = error instanceof Error ? error.message : String(error);
|
|
844
|
-
}
|
|
845
|
-
this.#scheduleChatRefresh();
|
|
846
|
-
this.#requestRender();
|
|
847
|
-
})();
|
|
848
|
-
this.#requestRender();
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/** Viewport scrolling for the chat transcript. Returns true when handled. */
|
|
852
|
-
#handleViewerNavigation(keyData: string): boolean {
|
|
853
|
-
const maxScroll = this.#lastMaxScroll;
|
|
854
|
-
const scrollBy = (delta: number) => {
|
|
855
|
-
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll));
|
|
856
|
-
this.#wasAtBottom = this.#scrollOffset >= maxScroll;
|
|
857
|
-
this.#requestRender();
|
|
858
|
-
};
|
|
859
|
-
if (keyData === "j" || matchesSelectDown(keyData)) {
|
|
860
|
-
scrollBy(1);
|
|
861
|
-
return true;
|
|
862
|
-
}
|
|
863
|
-
if (keyData === "k" || matchesSelectUp(keyData)) {
|
|
864
|
-
scrollBy(-1);
|
|
865
|
-
return true;
|
|
866
|
-
}
|
|
867
|
-
if (matchesKey(keyData, "pageDown")) {
|
|
868
|
-
scrollBy(PAGE_SIZE);
|
|
869
|
-
return true;
|
|
870
|
-
}
|
|
871
|
-
if (matchesKey(keyData, "pageUp")) {
|
|
872
|
-
scrollBy(-PAGE_SIZE);
|
|
873
|
-
return true;
|
|
874
|
-
}
|
|
875
|
-
if (keyData === "G") {
|
|
876
|
-
this.#scrollOffset = maxScroll;
|
|
877
|
-
this.#wasAtBottom = true;
|
|
878
|
-
this.#requestRender();
|
|
879
|
-
return true;
|
|
880
|
-
}
|
|
881
|
-
if (keyData === "g") {
|
|
882
|
-
this.#scrollOffset = 0;
|
|
883
|
-
this.#wasAtBottom = maxScroll === 0;
|
|
884
|
-
this.#requestRender();
|
|
885
|
-
return true;
|
|
886
|
-
}
|
|
887
|
-
return false;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// ========================================================================
|
|
891
|
-
// Transcript assembly — the same components as the main session transcript
|
|
892
|
-
// (mirrors UiHelpers.renderSessionContext / addMessageToChat).
|
|
893
|
-
// ========================================================================
|
|
894
|
-
|
|
895
|
-
/** Tear down transcript components (sealing pending spinners) and reset build state. */
|
|
896
|
-
#resetChatLog(): void {
|
|
897
|
-
for (const pending of this.#chatPendingTools.values()) pending.seal();
|
|
898
|
-
this.#chatPendingTools.clear();
|
|
899
|
-
this.#chatReadArgs.clear();
|
|
900
|
-
this.#chatReadGroup = null;
|
|
901
|
-
this.#pendingUsage = undefined;
|
|
902
|
-
this.#chatWaitingPoll = null;
|
|
903
|
-
this.#chatExpandables = [];
|
|
904
|
-
this.#chatLog.dispose();
|
|
905
|
-
this.#chatLog.clear();
|
|
906
|
-
this.#chatEntriesRef = undefined;
|
|
907
|
-
this.#chatBuiltCount = 0;
|
|
908
|
-
this.#chatPlaceholder = undefined;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */
|
|
912
|
-
#syncChatComponents(entries: SessionMessageEntry[]): void {
|
|
913
|
-
if (this.#chatEntriesRef !== entries) {
|
|
914
|
-
this.#resetChatLog();
|
|
915
|
-
this.#chatEntriesRef = entries;
|
|
916
|
-
}
|
|
917
|
-
for (let i = this.#chatBuiltCount; i < entries.length; i++) {
|
|
918
|
-
this.#appendChatMessage(entries[i].message);
|
|
919
|
-
}
|
|
920
|
-
this.#chatBuiltCount = entries.length;
|
|
921
|
-
// Flush the trailing turn's usage row only once its tools are materialized.
|
|
922
|
-
// A read (or any tool) whose toolResult lands in a later debounced sync stays
|
|
923
|
-
// pending in #chatReadArgs / #chatPendingTools; flushing now would emit the
|
|
924
|
-
// row above it. The sync that drains the maps flushes it below the tools.
|
|
925
|
-
if (this.#chatReadArgs.size === 0 && this.#chatPendingTools.size === 0) {
|
|
926
|
-
this.#flushPendingUsage();
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
#trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
|
|
931
|
-
component.setExpanded(this.#chatExpanded);
|
|
932
|
-
this.#chatExpandables.push(component);
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */
|
|
936
|
-
#resolveWaitingPoll(nextToolName?: string): void {
|
|
937
|
-
const previous = this.#chatWaitingPoll;
|
|
938
|
-
if (!previous) return;
|
|
939
|
-
this.#chatWaitingPoll = null;
|
|
940
|
-
if (nextToolName === "job" && previous.isDisplaceableBlock()) {
|
|
941
|
-
this.#chatLog.removeChild(previous);
|
|
942
|
-
}
|
|
943
|
-
previous.seal();
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
#ensureReadGroup(): ReadToolGroupComponent {
|
|
947
|
-
if (!this.#chatReadGroup) {
|
|
948
|
-
this.#chatReadGroup = new ReadToolGroupComponent({
|
|
949
|
-
showContentPreview: settings.get("read.toolResultPreview"),
|
|
950
|
-
});
|
|
951
|
-
this.#trackExpandable(this.#chatReadGroup);
|
|
952
|
-
this.#chatLog.addChild(this.#chatReadGroup);
|
|
953
|
-
}
|
|
954
|
-
return this.#chatReadGroup;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// The per-turn token-usage row must land below the turn's tool blocks, but
|
|
958
|
-
// normal `read` calls only materialize their group in #appendToolResult. Defer
|
|
959
|
-
// the row: stash it on the assistant message and flush once the turn's tools
|
|
960
|
-
// are placed — before the next non-toolResult message and at the end of each
|
|
961
|
-
// sync pass — sealing the read run so the row sits under it.
|
|
962
|
-
#flushPendingUsage(): void {
|
|
963
|
-
if (!this.#pendingUsage) return;
|
|
964
|
-
this.#chatReadGroup?.seal();
|
|
965
|
-
this.#chatReadGroup = null;
|
|
966
|
-
this.#chatLog.addChild(createUsageRowBlock(this.#pendingUsage));
|
|
967
|
-
this.#pendingUsage = undefined;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
#appendChatMessage(message: AgentMessage): void {
|
|
971
|
-
if (message.role !== "toolResult") this.#flushPendingUsage();
|
|
972
|
-
switch (message.role) {
|
|
973
|
-
case "assistant":
|
|
974
|
-
this.#appendAssistantMessage(message);
|
|
975
|
-
break;
|
|
976
|
-
case "toolResult":
|
|
977
|
-
this.#appendToolResult(message);
|
|
978
|
-
break;
|
|
979
|
-
case "user":
|
|
980
|
-
case "developer": {
|
|
981
|
-
// A user prompt closes the poll-displacement window, same as the live path.
|
|
982
|
-
if (message.role === "user") this.#resolveWaitingPoll();
|
|
983
|
-
const textContent =
|
|
984
|
-
message.role !== "user"
|
|
985
|
-
? ""
|
|
986
|
-
: typeof message.content === "string"
|
|
987
|
-
? message.content
|
|
988
|
-
: message.content
|
|
989
|
-
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
990
|
-
.map(block => block.text)
|
|
991
|
-
.join("");
|
|
992
|
-
if (textContent) {
|
|
993
|
-
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
|
|
994
|
-
this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic));
|
|
995
|
-
}
|
|
996
|
-
break;
|
|
997
|
-
}
|
|
998
|
-
case "bashExecution": {
|
|
999
|
-
const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext);
|
|
1000
|
-
if (message.output) component.appendOutput(message.output);
|
|
1001
|
-
component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
|
|
1002
|
-
this.#chatLog.addChild(component);
|
|
1003
|
-
break;
|
|
1004
|
-
}
|
|
1005
|
-
case "pythonExecution": {
|
|
1006
|
-
const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext);
|
|
1007
|
-
if (message.output) component.appendOutput(message.output);
|
|
1008
|
-
component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
|
|
1009
|
-
this.#chatLog.addChild(component);
|
|
1010
|
-
break;
|
|
1011
|
-
}
|
|
1012
|
-
case "hookMessage":
|
|
1013
|
-
case "custom":
|
|
1014
|
-
this.#appendCustomMessage(message);
|
|
1015
|
-
break;
|
|
1016
|
-
case "compactionSummary": {
|
|
1017
|
-
const component = new CompactionSummaryMessageComponent(message);
|
|
1018
|
-
this.#trackExpandable(component);
|
|
1019
|
-
this.#chatLog.addChild(component);
|
|
1020
|
-
break;
|
|
1021
|
-
}
|
|
1022
|
-
case "branchSummary": {
|
|
1023
|
-
const component = new BranchSummaryMessageComponent(message);
|
|
1024
|
-
this.#trackExpandable(component);
|
|
1025
|
-
this.#chatLog.addChild(component);
|
|
1026
|
-
break;
|
|
1027
|
-
}
|
|
1028
|
-
case "fileMention": {
|
|
1029
|
-
const block = new TranscriptBlock();
|
|
1030
|
-
for (const file of message.files) {
|
|
1031
|
-
let suffix: string;
|
|
1032
|
-
if (file.skippedReason === "tooLarge") {
|
|
1033
|
-
const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
|
|
1034
|
-
suffix = `(skipped: ${size})`;
|
|
1035
|
-
} else {
|
|
1036
|
-
suffix = file.image
|
|
1037
|
-
? "(image)"
|
|
1038
|
-
: file.lineCount === undefined
|
|
1039
|
-
? "(unknown lines)"
|
|
1040
|
-
: `(${file.lineCount} lines)`;
|
|
1041
|
-
}
|
|
1042
|
-
const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
|
|
1043
|
-
"accent",
|
|
1044
|
-
file.path,
|
|
1045
|
-
)} ${theme.fg("dim", suffix)}`;
|
|
1046
|
-
block.addChild(new Text(text, 0, 0));
|
|
1047
|
-
}
|
|
1048
|
-
if (block.children.length > 0) this.#chatLog.addChild(block);
|
|
1049
|
-
break;
|
|
1050
|
-
}
|
|
1051
|
-
default:
|
|
1052
|
-
message satisfies never;
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
#appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
|
|
1057
|
-
const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () =>
|
|
1058
|
-
this.#requestRender(),
|
|
1059
|
-
);
|
|
1060
|
-
this.#chatLog.addChild(assistantComponent);
|
|
1061
|
-
|
|
1062
|
-
const hasVisibleAssistantContent = message.content.some(
|
|
1063
|
-
content =>
|
|
1064
|
-
(content.type === "text" && canonicalizeMessage(content.text)) ||
|
|
1065
|
-
(content.type === "thinking" && canonicalizeMessage(content.thinking)),
|
|
1066
|
-
);
|
|
1067
|
-
if (hasVisibleAssistantContent) {
|
|
1068
|
-
// New visible turn content closes the current read run (mirrors rebuild).
|
|
1069
|
-
this.#chatReadGroup?.seal();
|
|
1070
|
-
this.#chatReadGroup = null;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
|
|
1074
|
-
const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
|
|
1075
|
-
const errorMessage = hasErrorStop
|
|
1076
|
-
? message.stopReason === "aborted"
|
|
1077
|
-
? resolveAbortLabel(message.errorMessage)
|
|
1078
|
-
: message.errorMessage || "Error"
|
|
1079
|
-
: null;
|
|
1080
|
-
|
|
1081
|
-
for (const content of message.content) {
|
|
1082
|
-
if (content.type !== "toolCall") continue;
|
|
1083
|
-
this.#resolveWaitingPoll(content.name);
|
|
1084
|
-
|
|
1085
|
-
if (
|
|
1086
|
-
content.name === "read" &&
|
|
1087
|
-
readArgsHaveTarget(content.arguments) &&
|
|
1088
|
-
!readArgsTargetInternalUrl(content.arguments)
|
|
1089
|
-
) {
|
|
1090
|
-
if (hasErrorStop && errorMessage) {
|
|
1091
|
-
const group = this.#ensureReadGroup();
|
|
1092
|
-
group.updateArgs(content.arguments, content.id);
|
|
1093
|
-
group.updateResult(
|
|
1094
|
-
{ content: [{ type: "text", text: errorMessage }], isError: true },
|
|
1095
|
-
false,
|
|
1096
|
-
content.id,
|
|
1097
|
-
);
|
|
1098
|
-
} else {
|
|
1099
|
-
const normalizedArgs =
|
|
1100
|
-
content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
|
|
1101
|
-
? (content.arguments as Record<string, unknown>)
|
|
1102
|
-
: {};
|
|
1103
|
-
this.#chatReadArgs.set(content.id, normalizedArgs);
|
|
1104
|
-
}
|
|
1105
|
-
continue;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
this.#chatReadGroup?.seal();
|
|
1109
|
-
this.#chatReadGroup = null;
|
|
1110
|
-
const component = new ToolExecutionComponent(
|
|
1111
|
-
content.name,
|
|
1112
|
-
content.arguments,
|
|
1113
|
-
{
|
|
1114
|
-
// Images can't be sliced through the scroll viewport; keep them off.
|
|
1115
|
-
showImages: false,
|
|
1116
|
-
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
1117
|
-
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
1118
|
-
liveRegion: this.#chatLog,
|
|
1119
|
-
},
|
|
1120
|
-
this.#getTool?.(content.name),
|
|
1121
|
-
this.#ui,
|
|
1122
|
-
this.#cwd,
|
|
1123
|
-
content.id,
|
|
1124
|
-
);
|
|
1125
|
-
this.#trackExpandable(component);
|
|
1126
|
-
this.#chatLog.addChild(component);
|
|
1127
|
-
|
|
1128
|
-
if (hasErrorStop && errorMessage) {
|
|
1129
|
-
component.updateResult(
|
|
1130
|
-
{ content: [{ type: "text", text: errorMessage }], isError: true },
|
|
1131
|
-
false,
|
|
1132
|
-
content.id,
|
|
1133
|
-
);
|
|
1134
|
-
} else {
|
|
1135
|
-
this.#chatPendingTools.set(content.id, component);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
#appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
|
|
1143
|
-
const pending = this.#chatPendingTools.get(message.toolCallId);
|
|
1144
|
-
const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
|
|
1145
|
-
if (isReadGroupResult) {
|
|
1146
|
-
let component = pending;
|
|
1147
|
-
if (!component) {
|
|
1148
|
-
const group = this.#ensureReadGroup();
|
|
1149
|
-
const args = this.#chatReadArgs.get(message.toolCallId);
|
|
1150
|
-
if (args) group.updateArgs(args, message.toolCallId);
|
|
1151
|
-
component = group;
|
|
1152
|
-
}
|
|
1153
|
-
component.updateResult(message, false, message.toolCallId);
|
|
1154
|
-
this.#chatPendingTools.delete(message.toolCallId);
|
|
1155
|
-
this.#chatReadArgs.delete(message.toolCallId);
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
if (!pending) return;
|
|
1159
|
-
pending.updateResult(message, false, message.toolCallId);
|
|
1160
|
-
this.#chatPendingTools.delete(message.toolCallId);
|
|
1161
|
-
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
|
1162
|
-
this.#chatWaitingPoll = pending;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
#appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
|
|
1167
|
-
if (!message.display) return;
|
|
1168
|
-
if (message.customType === "async-result") {
|
|
1169
|
-
const details = (
|
|
1170
|
-
message as CustomMessage<{
|
|
1171
|
-
jobId?: string;
|
|
1172
|
-
type?: "bash" | "task";
|
|
1173
|
-
label?: string;
|
|
1174
|
-
durationMs?: number;
|
|
1175
|
-
jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
|
|
1176
|
-
}>
|
|
1177
|
-
).details;
|
|
1178
|
-
const jobs =
|
|
1179
|
-
details?.jobs && details.jobs.length > 0
|
|
1180
|
-
? details.jobs
|
|
1181
|
-
: [
|
|
1182
|
-
{
|
|
1183
|
-
jobId: details?.jobId,
|
|
1184
|
-
type: details?.type,
|
|
1185
|
-
label: details?.label,
|
|
1186
|
-
durationMs: details?.durationMs,
|
|
1187
|
-
},
|
|
1188
|
-
];
|
|
1189
|
-
const block = new TranscriptBlock();
|
|
1190
|
-
for (const job of jobs) {
|
|
1191
|
-
const jobId = job.jobId ?? "unknown";
|
|
1192
|
-
const typeLabel = job.type ? `[${job.type}]` : "[job]";
|
|
1193
|
-
const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
|
|
1194
|
-
const line = [
|
|
1195
|
-
theme.fg("success", `${theme.status.done} Background job completed`),
|
|
1196
|
-
theme.fg("dim", typeLabel),
|
|
1197
|
-
theme.fg("accent", jobId),
|
|
1198
|
-
duration ? theme.fg("dim", `(${duration})`) : undefined,
|
|
1199
|
-
]
|
|
1200
|
-
.filter(Boolean)
|
|
1201
|
-
.join(" ");
|
|
1202
|
-
block.addChild(new Text(line, 1, 0));
|
|
1203
|
-
}
|
|
1204
|
-
this.#chatLog.addChild(block);
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
|
|
1208
|
-
const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
|
|
1209
|
-
const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
|
|
1210
|
-
this.#trackExpandable(component);
|
|
1211
|
-
this.#chatLog.addChild(component);
|
|
1212
|
-
return;
|
|
1213
|
-
}
|
|
1214
|
-
if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
|
|
1215
|
-
this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
|
|
1216
|
-
return;
|
|
1217
|
-
}
|
|
1218
|
-
if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
|
|
1219
|
-
const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
|
|
1220
|
-
this.#trackExpandable(component);
|
|
1221
|
-
this.#chatLog.addChild(component);
|
|
1222
|
-
return;
|
|
1223
|
-
}
|
|
1224
|
-
if (
|
|
1225
|
-
message.customType === "irc:incoming" ||
|
|
1226
|
-
message.customType === "irc:autoreply" ||
|
|
1227
|
-
message.customType === "irc:relay"
|
|
1228
|
-
) {
|
|
1229
|
-
const details = (
|
|
1230
|
-
message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
|
|
1231
|
-
).details;
|
|
1232
|
-
const kind =
|
|
1233
|
-
message.customType === "irc:incoming"
|
|
1234
|
-
? ("incoming" as const)
|
|
1235
|
-
: message.customType === "irc:autoreply"
|
|
1236
|
-
? ("autoreply" as const)
|
|
1237
|
-
: ("relay" as const);
|
|
1238
|
-
const card = createIrcMessageCard(
|
|
1239
|
-
{
|
|
1240
|
-
kind,
|
|
1241
|
-
from: details?.from,
|
|
1242
|
-
to: details?.to,
|
|
1243
|
-
body: kind === "incoming" ? details?.message : details?.body,
|
|
1244
|
-
replyTo: details?.replyTo,
|
|
1245
|
-
timestamp: message.timestamp,
|
|
1246
|
-
},
|
|
1247
|
-
() => this.#chatExpanded,
|
|
1248
|
-
theme,
|
|
1249
|
-
);
|
|
1250
|
-
this.#chatLog.addChild(card);
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
if (message.customType === "advisor") {
|
|
1254
|
-
const details = (message as CustomMessage<AdvisorMessageDetails>).details;
|
|
1255
|
-
this.#chatLog.addChild(createAdvisorMessageCard(details, () => this.#chatExpanded, theme));
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
|
|
1259
|
-
this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
const handoffComponent = createHandoffSummaryMessageComponent(
|
|
1263
|
-
message as CustomMessage<unknown>,
|
|
1264
|
-
this.#chatExpanded,
|
|
1265
|
-
);
|
|
1266
|
-
if (handoffComponent) {
|
|
1267
|
-
this.#trackExpandable(handoffComponent);
|
|
1268
|
-
this.#chatLog.addChild(handoffComponent);
|
|
1269
|
-
return;
|
|
1270
|
-
}
|
|
1271
|
-
const component = new CustomMessageComponent(
|
|
1272
|
-
message as CustomMessage<unknown>,
|
|
1273
|
-
this.#getMessageRenderer?.(message.customType),
|
|
1274
|
-
);
|
|
1275
|
-
this.#trackExpandable(component);
|
|
1276
|
-
this.#chatLog.addChild(component);
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
#loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
|
|
1280
|
-
if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
|
|
1281
|
-
this.#transcriptCache = undefined;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
|
|
1285
|
-
const result = readFileIncremental(sessionFile, fromByte);
|
|
1286
|
-
if (!result) {
|
|
1287
|
-
logger.debug("Agent hub: failed to read session file", { path: sessionFile });
|
|
1288
|
-
return this.#transcriptCache?.entries ?? null;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
if (result.newSize < fromByte) {
|
|
1292
|
-
this.#transcriptCache = undefined;
|
|
1293
|
-
return this.#loadTranscript(sessionFile);
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
this.#ingestTranscriptChunk(sessionFile, result.text, fromByte);
|
|
1297
|
-
return this.#transcriptCache?.entries ?? null;
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
/** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */
|
|
1301
|
-
#ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void {
|
|
1302
|
-
if (!this.#transcriptCache) {
|
|
1303
|
-
this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] };
|
|
1304
|
-
}
|
|
1305
|
-
if (text.length === 0) return;
|
|
1306
|
-
const lastNewline = text.lastIndexOf("\n");
|
|
1307
|
-
if (lastNewline < 0) return;
|
|
1308
|
-
const completeChunk = text.slice(0, lastNewline + 1);
|
|
1309
|
-
const newEntries = parseSessionEntries(completeChunk);
|
|
1310
|
-
for (const entry of newEntries) {
|
|
1311
|
-
if (entry.type === "message") {
|
|
1312
|
-
this.#transcriptCache.entries.push(entry);
|
|
1313
|
-
// Extract model from first assistant message
|
|
1314
|
-
const msg = entry.message;
|
|
1315
|
-
if (!this.#transcriptCache.model && msg.role === "assistant") {
|
|
1316
|
-
this.#transcriptCache.model = msg.model;
|
|
1317
|
-
}
|
|
1318
|
-
} else if (entry.type === "model_change") {
|
|
1319
|
-
this.#transcriptCache.model = entry.model;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
/** Kick an incremental transcript fetch from the collab host (single-flight). */
|
|
1326
|
-
#fetchRemoteTranscript(id: string): void {
|
|
1327
|
-
const remote = this.#remote;
|
|
1328
|
-
if (!remote || this.#remoteFetchInFlight) return;
|
|
1329
|
-
const cacheKey = `remote:${id}`;
|
|
1330
|
-
if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) {
|
|
1331
|
-
this.#transcriptCache = undefined;
|
|
1332
|
-
}
|
|
1333
|
-
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
|
|
1334
|
-
this.#remoteFetchInFlight = true;
|
|
1335
|
-
const token = ++this.#remoteFetchToken;
|
|
1336
|
-
void remote
|
|
1337
|
-
.readTranscript(id, fromByte)
|
|
1338
|
-
.then(result => {
|
|
1339
|
-
if (token !== this.#remoteFetchToken) return;
|
|
1340
|
-
this.#remoteFetchInFlight = false;
|
|
1341
|
-
if (this.#chatAgentId !== id) return;
|
|
1342
|
-
if (!result) {
|
|
1343
|
-
if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) {
|
|
1344
|
-
if (!this.#remoteTranscriptUnavailable) {
|
|
1345
|
-
this.#remoteTranscriptUnavailable = true;
|
|
1346
|
-
this.#scheduleChatRefresh();
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
if (result.newSize < fromByte) {
|
|
1352
|
-
// Host transcript truncated/rotated — restart from 0.
|
|
1353
|
-
this.#transcriptCache = undefined;
|
|
1354
|
-
this.#fetchRemoteTranscript(id);
|
|
1355
|
-
return;
|
|
1356
|
-
}
|
|
1357
|
-
this.#remoteTranscriptUnavailable = false;
|
|
1358
|
-
const hadCache = this.#transcriptCache !== undefined;
|
|
1359
|
-
const before = this.#transcriptCache?.entries.length ?? 0;
|
|
1360
|
-
this.#ingestTranscriptChunk(cacheKey, result.text, fromByte);
|
|
1361
|
-
const after = this.#transcriptCache?.entries.length ?? 0;
|
|
1362
|
-
// Only refresh on new content (or first completed fetch) — an
|
|
1363
|
-
// unconditional rebuild would re-kick the fetch in a tight loop.
|
|
1364
|
-
if (after > before || !hadCache) this.#scheduleChatRefresh();
|
|
1365
|
-
})
|
|
1366
|
-
.catch((error: unknown) => {
|
|
1367
|
-
if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false;
|
|
1368
|
-
logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) });
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// Sync helper for the render path
|
|
1374
|
-
function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
|
|
1375
|
-
try {
|
|
1376
|
-
const stat = fs.statSync(filePath);
|
|
1377
|
-
if (stat.size <= fromByte) return { text: "", newSize: stat.size };
|
|
1378
|
-
const buf = Buffer.alloc(stat.size - fromByte);
|
|
1379
|
-
const fd = fs.openSync(filePath, "r");
|
|
1380
|
-
try {
|
|
1381
|
-
fs.readSync(fd, buf, 0, buf.length, fromByte);
|
|
1382
|
-
} finally {
|
|
1383
|
-
fs.closeSync(fd);
|
|
1384
|
-
}
|
|
1385
|
-
return { text: buf.toString("utf-8"), newSize: stat.size };
|
|
1386
|
-
} catch {
|
|
1387
|
-
return null;
|
|
1388
|
-
}
|
|
1389
566
|
}
|