@oh-my-pi/pi-coding-agent 15.11.7 → 15.11.8
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 +30 -2
- package/dist/cli.js +363 -356
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/collab/crypto.d.ts +12 -0
- package/dist/types/collab/guest.d.ts +21 -0
- package/dist/types/collab/host.d.ts +13 -0
- package/dist/types/collab/protocol.d.ts +100 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +21 -1
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/modes/components/agent-hub.d.ts +13 -0
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/status-line/component.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/types.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +6 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/package.json +14 -12
- package/scripts/bench-guard.ts +71 -0
- package/src/cli/args.ts +2 -0
- package/src/cli-commands.ts +1 -0
- package/src/collab/crypto.ts +57 -0
- package/src/collab/guest.ts +421 -0
- package/src/collab/host.ts +494 -0
- package/src/collab/protocol.ts +191 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +22 -14
- package/src/config/settings-schema.ts +27 -1
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/internal-urls/docs-index.generated.ts +3 -2
- package/src/main.ts +11 -2
- package/src/modes/components/agent-hub.ts +119 -22
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/status-line/component.ts +21 -1
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +13 -0
- package/src/modes/components/status-line/types.ts +10 -0
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/controllers/input-controller.ts +72 -6
- package/src/modes/controllers/selector-controller.ts +2 -0
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +12 -4
- package/src/modes/types.ts +8 -0
- package/src/modes/utils/ui-helpers.ts +7 -0
- package/src/sdk.ts +239 -36
- package/src/session/agent-session.ts +17 -0
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +9 -3
- package/src/slash-commands/builtin-registry.ts +210 -0
- package/src/tools/read.ts +38 -5
- package/src/tools/write.ts +13 -42
package/src/main.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
import type { AgentSession } from "./session/agent-session";
|
|
67
67
|
import type { AuthStorage } from "./session/auth-storage";
|
|
68
68
|
import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
|
|
69
|
+
import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
|
|
69
70
|
import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
|
|
70
71
|
import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
|
|
71
72
|
import { AUTO_THINKING } from "./thinking";
|
|
@@ -346,6 +347,7 @@ async function runInteractiveMode(
|
|
|
346
347
|
initialMessage?: string,
|
|
347
348
|
initialImages?: ImageContent[],
|
|
348
349
|
titleSystemPrompt?: string,
|
|
350
|
+
joinLink?: string,
|
|
349
351
|
): Promise<void> {
|
|
350
352
|
const mode = new InteractiveMode(
|
|
351
353
|
session,
|
|
@@ -414,6 +416,12 @@ async function runInteractiveMode(
|
|
|
414
416
|
}
|
|
415
417
|
}
|
|
416
418
|
|
|
419
|
+
// `omp join <link>`: dispatch through the same builtin path as a typed
|
|
420
|
+
// `/join` so collab guards and error rendering stay in one place.
|
|
421
|
+
if (joinLink !== undefined) {
|
|
422
|
+
await executeBuiltinSlashCommand(`/join ${joinLink}`, { ctx: mode });
|
|
423
|
+
}
|
|
424
|
+
|
|
417
425
|
if (initialMessage !== undefined) {
|
|
418
426
|
try {
|
|
419
427
|
using _keepalive = new EventLoopKeepalive();
|
|
@@ -889,7 +897,7 @@ export async function runRootCommand(
|
|
|
889
897
|
|
|
890
898
|
// Create AuthStorage and ModelRegistry upfront
|
|
891
899
|
const authStorage = await logger.time("discoverAuthStorage", deps.discoverAuthStorage ?? discoverAuthStorage);
|
|
892
|
-
const modelRegistry = new ModelRegistry(authStorage);
|
|
900
|
+
const modelRegistry = logger.time("modelRegistry:init", () => new ModelRegistry(authStorage));
|
|
893
901
|
|
|
894
902
|
if (parsedArgs.version) {
|
|
895
903
|
process.stdout.write(`${VERSION}\n`);
|
|
@@ -1138,7 +1146,7 @@ export async function runRootCommand(
|
|
|
1138
1146
|
// Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
|
|
1139
1147
|
// is enough to enable telemetry — content capture is governed by the
|
|
1140
1148
|
// standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
|
|
1141
|
-
await initTelemetryExport
|
|
1149
|
+
await logger.time("initTelemetryExport", initTelemetryExport);
|
|
1142
1150
|
if (isTelemetryExportEnabled()) {
|
|
1143
1151
|
sessionOptions.telemetry = {};
|
|
1144
1152
|
}
|
|
@@ -1298,6 +1306,7 @@ export async function runRootCommand(
|
|
|
1298
1306
|
initialMessage,
|
|
1299
1307
|
initialImages,
|
|
1300
1308
|
titleSystemPrompt,
|
|
1309
|
+
parsedArgs.join,
|
|
1301
1310
|
);
|
|
1302
1311
|
} else {
|
|
1303
1312
|
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
|
|
@@ -81,6 +81,15 @@ function statusBadge(status: AgentStatus): string {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Guest-side proxy for hub actions executed on the collab host. */
|
|
85
|
+
export interface AgentHubRemote {
|
|
86
|
+
chat(id: string, text: string): void;
|
|
87
|
+
kill(id: string): void;
|
|
88
|
+
revive(id: string): void;
|
|
89
|
+
/** Mirrors readFileIncremental: text from fromByte (complete JSONL lines), newSize = next fromByte base; null = unavailable. */
|
|
90
|
+
readTranscript(id: string, fromByte: number): Promise<{ text: string; newSize: number } | null>;
|
|
91
|
+
}
|
|
92
|
+
|
|
84
93
|
export interface AgentHubDeps {
|
|
85
94
|
/** Progress/status snapshot source (task lifecycle + progress channels). */
|
|
86
95
|
observers: SessionObserverRegistry;
|
|
@@ -94,6 +103,8 @@ export interface AgentHubDeps {
|
|
|
94
103
|
lifecycle?: AgentLifecycleManager;
|
|
95
104
|
/** Injectable for tests; defaults to the process-global bus. */
|
|
96
105
|
irc?: IrcBus;
|
|
106
|
+
/** Collab guest: route actions/transcripts to the host instead of local sessions. */
|
|
107
|
+
remote?: AgentHubRemote;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
export class AgentHubOverlayComponent extends Container {
|
|
@@ -106,6 +117,11 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
106
117
|
#hubKeys: KeyId[];
|
|
107
118
|
#unsubscribers: Array<() => void> = [];
|
|
108
119
|
#ageTimer: NodeJS.Timeout | undefined;
|
|
120
|
+
#remote: AgentHubRemote | undefined;
|
|
121
|
+
#remoteFetchInFlight = false;
|
|
122
|
+
/** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */
|
|
123
|
+
#remoteFetchToken = 0;
|
|
124
|
+
#remoteTranscriptUnavailable = false;
|
|
109
125
|
|
|
110
126
|
// Table state
|
|
111
127
|
#view: "table" | "chat" = "table";
|
|
@@ -143,6 +159,7 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
143
159
|
this.#onDone = deps.onDone;
|
|
144
160
|
this.#requestRender = deps.requestRender;
|
|
145
161
|
this.#hubKeys = deps.hubKeys;
|
|
162
|
+
this.#remote = deps.remote;
|
|
146
163
|
|
|
147
164
|
this.#editor = new Editor(getEditorTheme());
|
|
148
165
|
this.#editor.setMaxHeight(4);
|
|
@@ -196,6 +213,9 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
196
213
|
this.#chatAgentId = id;
|
|
197
214
|
this.#notice = undefined;
|
|
198
215
|
this.#transcriptCache = undefined;
|
|
216
|
+
this.#remoteTranscriptUnavailable = false;
|
|
217
|
+
this.#remoteFetchInFlight = false;
|
|
218
|
+
this.#remoteFetchToken++;
|
|
199
219
|
this.#scrollOffset = 0;
|
|
200
220
|
this.#selectedEntryIndex = 0;
|
|
201
221
|
this.#expandedEntries.clear();
|
|
@@ -238,6 +258,8 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
238
258
|
|
|
239
259
|
/** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
|
|
240
260
|
#attachLiveSession(): void {
|
|
261
|
+
// Remote refs carry no live session handle; refreshes come from observer onChange.
|
|
262
|
+
if (this.#remote) return;
|
|
241
263
|
const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
|
|
242
264
|
if (session === this.#attachedSession) return;
|
|
243
265
|
this.#detachLiveSession();
|
|
@@ -391,6 +413,11 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
391
413
|
return;
|
|
392
414
|
}
|
|
393
415
|
this.#notice = undefined;
|
|
416
|
+
if (this.#remote) {
|
|
417
|
+
this.#remote.revive(ref.id);
|
|
418
|
+
this.#requestRender();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
394
421
|
// Fire-and-forget; failures surface as an inline notice
|
|
395
422
|
this.#lifecycle()
|
|
396
423
|
.ensureLive(ref.id)
|
|
@@ -405,6 +432,12 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
405
432
|
const ref = this.#rows[this.#selectedRow];
|
|
406
433
|
if (!ref) return;
|
|
407
434
|
this.#notice = undefined;
|
|
435
|
+
if (this.#remote) {
|
|
436
|
+
this.#remote.kill(ref.id);
|
|
437
|
+
this.#refreshRows();
|
|
438
|
+
this.#requestRender();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
408
441
|
void (async () => {
|
|
409
442
|
try {
|
|
410
443
|
if (ref.status === "running" && ref.session) {
|
|
@@ -512,7 +545,10 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
512
545
|
|
|
513
546
|
// Load transcript first so model info is available for the header
|
|
514
547
|
let messageEntries: SessionMessageEntry[] | null = null;
|
|
515
|
-
if (
|
|
548
|
+
if (this.#remote) {
|
|
549
|
+
if (id) this.#fetchRemoteTranscript(id);
|
|
550
|
+
messageEntries = this.#transcriptCache?.entries ?? [];
|
|
551
|
+
} else if (ref?.sessionFile) {
|
|
516
552
|
messageEntries = this.#loadTranscript(ref.sessionFile);
|
|
517
553
|
}
|
|
518
554
|
|
|
@@ -530,12 +566,18 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
530
566
|
this.#viewerEntries = [];
|
|
531
567
|
if (!ref) {
|
|
532
568
|
contentLines.push(theme.fg("dim", "Agent no longer registered."));
|
|
533
|
-
} else if (!ref.sessionFile) {
|
|
569
|
+
} else if (!this.#remote && !ref.sessionFile) {
|
|
534
570
|
contentLines.push(theme.fg("dim", "No session file available yet."));
|
|
535
571
|
} else if (!messageEntries) {
|
|
536
572
|
contentLines.push(theme.fg("dim", "Unable to read session file."));
|
|
537
573
|
} else if (messageEntries.length === 0) {
|
|
538
|
-
|
|
574
|
+
if (this.#remote && this.#remoteTranscriptUnavailable) {
|
|
575
|
+
contentLines.push(theme.fg("dim", "Transcript lives on the host — not available."));
|
|
576
|
+
} else if (this.#remote && !this.#transcriptCache) {
|
|
577
|
+
contentLines.push(theme.fg("dim", "Loading transcript from host…"));
|
|
578
|
+
} else {
|
|
579
|
+
contentLines.push(theme.fg("dim", "No messages yet."));
|
|
580
|
+
}
|
|
539
581
|
} else {
|
|
540
582
|
this.#buildTranscriptLines(messageEntries, contentLines);
|
|
541
583
|
}
|
|
@@ -580,6 +622,12 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
580
622
|
if (!id || !trimmed) return;
|
|
581
623
|
this.#editor.setText("");
|
|
582
624
|
this.#notice = undefined;
|
|
625
|
+
if (this.#remote) {
|
|
626
|
+
this.#remote.chat(id, trimmed);
|
|
627
|
+
this.#scheduleChatRefresh();
|
|
628
|
+
this.#requestRender();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
583
631
|
void (async () => {
|
|
584
632
|
try {
|
|
585
633
|
// Revives a parked agent; returns the live session for running/idle.
|
|
@@ -1024,31 +1072,80 @@ export class AgentHubOverlayComponent extends Container {
|
|
|
1024
1072
|
return this.#loadTranscript(sessionFile);
|
|
1025
1073
|
}
|
|
1026
1074
|
|
|
1075
|
+
this.#ingestTranscriptChunk(sessionFile, result.text, fromByte);
|
|
1076
|
+
return this.#transcriptCache?.entries ?? null;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */
|
|
1080
|
+
#ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void {
|
|
1027
1081
|
if (!this.#transcriptCache) {
|
|
1028
|
-
this.#transcriptCache = { path:
|
|
1082
|
+
this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] };
|
|
1029
1083
|
}
|
|
1084
|
+
if (text.length === 0) return;
|
|
1085
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
1086
|
+
if (lastNewline < 0) return;
|
|
1087
|
+
const completeChunk = text.slice(0, lastNewline + 1);
|
|
1088
|
+
const newEntries = parseSessionEntries(completeChunk);
|
|
1089
|
+
for (const entry of newEntries) {
|
|
1090
|
+
if (entry.type === "message") {
|
|
1091
|
+
this.#transcriptCache.entries.push(entry);
|
|
1092
|
+
// Extract model from first assistant message
|
|
1093
|
+
const msg = entry.message;
|
|
1094
|
+
if (!this.#transcriptCache.model && msg.role === "assistant") {
|
|
1095
|
+
this.#transcriptCache.model = msg.model;
|
|
1096
|
+
}
|
|
1097
|
+
} else if (entry.type === "model_change") {
|
|
1098
|
+
this.#transcriptCache.model = entry.model;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
1102
|
+
}
|
|
1030
1103
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1104
|
+
/** Kick an incremental transcript fetch from the collab host (single-flight). */
|
|
1105
|
+
#fetchRemoteTranscript(id: string): void {
|
|
1106
|
+
const remote = this.#remote;
|
|
1107
|
+
if (!remote || this.#remoteFetchInFlight) return;
|
|
1108
|
+
const cacheKey = `remote:${id}`;
|
|
1109
|
+
if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) {
|
|
1110
|
+
this.#transcriptCache = undefined;
|
|
1111
|
+
}
|
|
1112
|
+
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
|
|
1113
|
+
this.#remoteFetchInFlight = true;
|
|
1114
|
+
const token = ++this.#remoteFetchToken;
|
|
1115
|
+
void remote
|
|
1116
|
+
.readTranscript(id, fromByte)
|
|
1117
|
+
.then(result => {
|
|
1118
|
+
if (token !== this.#remoteFetchToken) return;
|
|
1119
|
+
this.#remoteFetchInFlight = false;
|
|
1120
|
+
if (this.#chatAgentId !== id) return;
|
|
1121
|
+
if (!result) {
|
|
1122
|
+
if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) {
|
|
1123
|
+
if (!this.#remoteTranscriptUnavailable) {
|
|
1124
|
+
this.#remoteTranscriptUnavailable = true;
|
|
1125
|
+
this.#scheduleChatRefresh();
|
|
1043
1126
|
}
|
|
1044
|
-
} else if (entry.type === "model_change") {
|
|
1045
|
-
this.#transcriptCache.model = entry.model;
|
|
1046
1127
|
}
|
|
1128
|
+
return;
|
|
1047
1129
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1130
|
+
if (result.newSize < fromByte) {
|
|
1131
|
+
// Host transcript truncated/rotated — restart from 0.
|
|
1132
|
+
this.#transcriptCache = undefined;
|
|
1133
|
+
this.#fetchRemoteTranscript(id);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
this.#remoteTranscriptUnavailable = false;
|
|
1137
|
+
const hadCache = this.#transcriptCache !== undefined;
|
|
1138
|
+
const before = this.#transcriptCache?.entries.length ?? 0;
|
|
1139
|
+
this.#ingestTranscriptChunk(cacheKey, result.text, fromByte);
|
|
1140
|
+
const after = this.#transcriptCache?.entries.length ?? 0;
|
|
1141
|
+
// Only refresh on new content (or first completed fetch) — an
|
|
1142
|
+
// unconditional rebuild would re-kick the fetch in a tight loop.
|
|
1143
|
+
if (after > before || !hadCache) this.#scheduleChatRefresh();
|
|
1144
|
+
})
|
|
1145
|
+
.catch((error: unknown) => {
|
|
1146
|
+
if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false;
|
|
1147
|
+
logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) });
|
|
1148
|
+
});
|
|
1052
1149
|
}
|
|
1053
1150
|
}
|
|
1054
1151
|
|
|
@@ -49,6 +49,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
49
49
|
/** Whether the last updateContent carried an in-flight streaming partial; such
|
|
50
50
|
* renders bypass the markdown module LRU (see Markdown.transientRenderCache). */
|
|
51
51
|
#lastUpdateTransient = false;
|
|
52
|
+
// Fast-path state: reuse Markdown children when message shape is stable during streaming.
|
|
53
|
+
#fastPathKey: string | undefined;
|
|
54
|
+
#fastPathItems:
|
|
55
|
+
| Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
|
|
56
|
+
| undefined;
|
|
52
57
|
|
|
53
58
|
constructor(
|
|
54
59
|
message?: AssistantMessage,
|
|
@@ -71,6 +76,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
71
76
|
|
|
72
77
|
override invalidate(): void {
|
|
73
78
|
super.invalidate();
|
|
79
|
+
// Theme/symbol changes arrive via invalidate(). Fast-path children captured
|
|
80
|
+
// getMarkdownTheme() at construction, so drop them and force the teardown
|
|
81
|
+
// path to rebuild with the current theme. Streaming updates call
|
|
82
|
+
// updateContent() directly and keep the fast path.
|
|
83
|
+
this.#fastPathKey = undefined;
|
|
84
|
+
this.#fastPathItems = undefined;
|
|
74
85
|
if (this.#lastMessage) {
|
|
75
86
|
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
76
87
|
}
|
|
@@ -228,14 +239,111 @@ export class AssistantMessageComponent extends Container {
|
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
#computeShapeKey(message: AssistantMessage): string {
|
|
243
|
+
const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
|
|
244
|
+
for (const content of message.content) {
|
|
245
|
+
if (content.type === "text") {
|
|
246
|
+
parts.push(content.text.trim() ? "T1" : "T0");
|
|
247
|
+
} else if (content.type === "thinking") {
|
|
248
|
+
if (!content.thinking.trim()) parts.push("K0");
|
|
249
|
+
else if (this.hideThinkingBlock) parts.push("KH");
|
|
250
|
+
else parts.push("KV");
|
|
251
|
+
} else {
|
|
252
|
+
// Non-rendered blocks (toolCall, redactedThinking, …) still occupy a
|
|
253
|
+
// content index. Encode their position so an inserted/removed one shifts
|
|
254
|
+
// the key and forces the teardown path instead of mis-indexing children.
|
|
255
|
+
parts.push(`O:${content.type}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (settings.get("display.showTokenUsage") && this.#usageInfo) {
|
|
259
|
+
const u = this.#usageInfo;
|
|
260
|
+
parts.push(`u:${u.input + u.cacheWrite}:${u.output}:${u.cacheRead}`);
|
|
261
|
+
} else {
|
|
262
|
+
parts.push("u:");
|
|
263
|
+
}
|
|
264
|
+
return parts.join("|");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#canFastPath(message: AssistantMessage): boolean {
|
|
268
|
+
for (const content of message.content) {
|
|
269
|
+
if (content.type === "toolCall") return false;
|
|
270
|
+
}
|
|
271
|
+
if (this.#toolImagesByCallId.size > 0) return false;
|
|
272
|
+
if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) return false;
|
|
273
|
+
if (message.stopReason === "error" && !this.#errorPinned) return false;
|
|
274
|
+
if (
|
|
275
|
+
message.errorMessage &&
|
|
276
|
+
shouldRenderAbortReason(message.errorMessage) &&
|
|
277
|
+
message.stopReason !== "aborted" &&
|
|
278
|
+
message.stopReason !== "error"
|
|
279
|
+
)
|
|
280
|
+
return false;
|
|
281
|
+
// Extension stability: if thinking renderers exist and any tracked thinking
|
|
282
|
+
// block's text changed, extensions may produce a different child count.
|
|
283
|
+
if (this.thinkingRenderers.length > 0 && this.#fastPathItems) {
|
|
284
|
+
for (const item of this.#fastPathItems) {
|
|
285
|
+
if (item.blockType === "thinking") {
|
|
286
|
+
const content = message.content[item.contentIndex];
|
|
287
|
+
if (content?.type === "thinking" && content.thinking.trim() !== item.lastText) return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#tryFastPathUpdate(message: AssistantMessage, opts?: { transient?: boolean }): boolean {
|
|
295
|
+
if (!this.#fastPathKey || !this.#fastPathItems) return false;
|
|
296
|
+
if (!this.#canFastPath(message)) {
|
|
297
|
+
this.#fastPathKey = undefined;
|
|
298
|
+
this.#fastPathItems = undefined;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (this.#computeShapeKey(message) !== this.#fastPathKey) {
|
|
302
|
+
this.#fastPathKey = undefined;
|
|
303
|
+
this.#fastPathItems = undefined;
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const transient = opts?.transient === true;
|
|
307
|
+
// Shape is identical — setText only on Markdown children whose source changed.
|
|
308
|
+
for (const item of this.#fastPathItems) {
|
|
309
|
+
item.md.transientRenderCache = transient;
|
|
310
|
+
const content = message.content[item.contentIndex];
|
|
311
|
+
let newText: string;
|
|
312
|
+
if (item.blockType === "text" && content?.type === "text") {
|
|
313
|
+
newText = content.text.trim();
|
|
314
|
+
} else if (item.blockType === "thinking" && content?.type === "thinking") {
|
|
315
|
+
newText = content.thinking.trim();
|
|
316
|
+
} else {
|
|
317
|
+
// Block at this index is gone or changed type (index shift) — fail closed.
|
|
318
|
+
this.#fastPathKey = undefined;
|
|
319
|
+
this.#fastPathItems = undefined;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (newText !== item.lastText) {
|
|
323
|
+
item.md.setText(newText);
|
|
324
|
+
item.lastText = newText;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
231
330
|
updateContent(message: AssistantMessage, opts?: { transient?: boolean }): void {
|
|
232
331
|
this.#blockVersion++;
|
|
233
332
|
this.#lastMessage = message;
|
|
234
333
|
this.#lastUpdateTransient = opts?.transient === true;
|
|
235
334
|
|
|
335
|
+
// Fast path: reuse Markdown children when shape is stable during streaming
|
|
336
|
+
if (this.#tryFastPathUpdate(message)) return;
|
|
337
|
+
|
|
236
338
|
// Clear content container
|
|
237
339
|
this.#contentContainer.clear();
|
|
238
340
|
|
|
341
|
+
// Determine if we should capture Markdown instances for next fast path
|
|
342
|
+
const shouldCapture = this.#canFastPath(message);
|
|
343
|
+
const captureItems:
|
|
344
|
+
| Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
|
|
345
|
+
| undefined = shouldCapture ? [] : undefined;
|
|
346
|
+
|
|
239
347
|
const hasVisibleContent = message.content.some(
|
|
240
348
|
c =>
|
|
241
349
|
(c.type === "text" && c.text.trim()) ||
|
|
@@ -249,9 +357,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
249
357
|
if (content.type === "text" && content.text.trim()) {
|
|
250
358
|
// Assistant text messages with no background - trim the text
|
|
251
359
|
// Set paddingY=0 to avoid extra spacing before tool executions
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
this.#
|
|
360
|
+
const trimmed = content.text.trim();
|
|
361
|
+
const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
|
|
362
|
+
md.transientRenderCache = this.#lastUpdateTransient;
|
|
363
|
+
this.#contentContainer.addChild(md);
|
|
364
|
+
captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
|
|
255
365
|
} else if (content.type === "thinking" && content.thinking.trim()) {
|
|
256
366
|
if (this.hideThinkingBlock) {
|
|
257
367
|
thinkingIndex += 1;
|
|
@@ -265,12 +375,13 @@ export class AssistantMessageComponent extends Container {
|
|
|
265
375
|
|
|
266
376
|
const thinkingText = content.thinking.trim();
|
|
267
377
|
// Thinking traces in thinkingText color, italic
|
|
268
|
-
const
|
|
378
|
+
const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
|
|
269
379
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
270
380
|
italic: true,
|
|
271
381
|
});
|
|
272
|
-
|
|
273
|
-
this.#contentContainer.addChild(
|
|
382
|
+
md.transientRenderCache = this.#lastUpdateTransient;
|
|
383
|
+
this.#contentContainer.addChild(md);
|
|
384
|
+
captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
|
|
274
385
|
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
275
386
|
thinkingIndex += 1;
|
|
276
387
|
if (hasVisibleContentAfter) {
|
|
@@ -318,5 +429,14 @@ export class AssistantMessageComponent extends Container {
|
|
|
318
429
|
this.#contentContainer.addChild(new Spacer(1));
|
|
319
430
|
this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
|
|
320
431
|
}
|
|
432
|
+
|
|
433
|
+
// Store fast-path state for next call
|
|
434
|
+
if (shouldCapture) {
|
|
435
|
+
this.#fastPathItems = captureItems;
|
|
436
|
+
this.#fastPathKey = this.#computeShapeKey(message);
|
|
437
|
+
} else {
|
|
438
|
+
this.#fastPathKey = undefined;
|
|
439
|
+
this.#fastPathItems = undefined;
|
|
440
|
+
}
|
|
321
441
|
}
|
|
322
442
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import type { CollabPromptDetails } from "../../collab/protocol";
|
|
4
|
+
import type { CustomMessage } from "../../session/messages";
|
|
5
|
+
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a collab guest prompt on every participant's transcript: a
|
|
9
|
+
* user-message-styled bubble prefixed with the author's name.
|
|
10
|
+
*/
|
|
11
|
+
export class CollabPromptMessageComponent extends Container {
|
|
12
|
+
constructor(message: CustomMessage<CollabPromptDetails>) {
|
|
13
|
+
super();
|
|
14
|
+
const from = message.details?.from?.trim() || "guest";
|
|
15
|
+
this.addChild(new Text(theme.fg("accent", `\x1b[1m«${from}»\x1b[22m ›`), 1, 0));
|
|
16
|
+
const text =
|
|
17
|
+
typeof message.content === "string"
|
|
18
|
+
? message.content
|
|
19
|
+
: message.content
|
|
20
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
21
|
+
.map(content => content.text)
|
|
22
|
+
.join("");
|
|
23
|
+
this.addChild(
|
|
24
|
+
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
|
25
|
+
bgColor: (value: string) => theme.bg("userMessageBg", value),
|
|
26
|
+
color: (value: string) => theme.fg("userMessageText", value),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -31,13 +31,12 @@ import { CountdownTimer } from "./countdown-timer";
|
|
|
31
31
|
import { DynamicBorder } from "./dynamic-border";
|
|
32
32
|
import { renderSegmentTrack } from "./segment-track";
|
|
33
33
|
|
|
34
|
-
/** One segment of a {@link HookSelectorSlider} — a label
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
/** One segment of a {@link HookSelectorSlider} — a label and an optional
|
|
35
|
+
* detail line (e.g. the resolved model name) shown beneath the track while
|
|
36
|
+
* the segment is active. Segment colors come from the track's theme palette,
|
|
37
|
+
* assigned by position. */
|
|
37
38
|
export interface HookSelectorSliderSegment {
|
|
38
39
|
label: string;
|
|
39
|
-
/** Theme color for the segment label; defaults to `accent`. */
|
|
40
|
-
color?: ThemeColor;
|
|
41
40
|
/** Secondary line rendered under the track when this segment is selected. */
|
|
42
41
|
detail?: string;
|
|
43
42
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared renderer for a horizontal row of colored "segments" styled after the
|
|
3
|
-
* status line: each segment
|
|
4
|
-
* as a powerline chip (its
|
|
5
|
-
* flanked by triangle caps) and the
|
|
6
|
-
* thin separator.
|
|
3
|
+
* status line: each segment is colored by its track position from the theme's
|
|
4
|
+
* own palette, the active one is filled as a powerline chip (its color as the
|
|
5
|
+
* background, a luminance-matched label, flanked by triangle caps) and the
|
|
6
|
+
* rest are plain colored labels joined by a thin separator.
|
|
7
7
|
*
|
|
8
8
|
* Used by the plan-mode model-tier slider ({@link HookSelectorComponent}) and
|
|
9
9
|
* the ctrl+p role-cycle status so both surfaces read identically.
|
|
@@ -12,13 +12,49 @@ import { type ThemeColor, theme } from "../theme/theme";
|
|
|
12
12
|
|
|
13
13
|
export interface TrackSegment {
|
|
14
14
|
label: string;
|
|
15
|
-
/** Theme color for the segment; defaults to `accent`. */
|
|
16
|
-
color?: ThemeColor;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
const FG_RESET = "\x1b[39m";
|
|
20
18
|
const BG_RESET = "\x1b[49m";
|
|
21
19
|
|
|
20
|
+
/** Vivid theme colors for position-based segment coloring, in preference
|
|
21
|
+
* order. Themes alias many of these to the same value (titanium maps most of
|
|
22
|
+
* the syntax set onto its accent), so {@link resolveSegmentPalette} dedupes
|
|
23
|
+
* by resolved escape and hands position i the i-th distinct color. */
|
|
24
|
+
const SEGMENT_COLOR_CANDIDATES: ThemeColor[] = [
|
|
25
|
+
"accent",
|
|
26
|
+
"success",
|
|
27
|
+
"warning",
|
|
28
|
+
"error",
|
|
29
|
+
"mdCode",
|
|
30
|
+
"mdLink",
|
|
31
|
+
"syntaxString",
|
|
32
|
+
"syntaxKeyword",
|
|
33
|
+
"syntaxFunction",
|
|
34
|
+
"syntaxNumber",
|
|
35
|
+
"syntaxOperator",
|
|
36
|
+
"syntaxVariable",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve up to `count` theme colors that render distinctly under the active
|
|
41
|
+
* theme, in candidate preference order. May return fewer than `count` when the
|
|
42
|
+
* theme has fewer distinct hues (e.g. monochrome themes) — callers wrap with
|
|
43
|
+
* modulo. Never returns an empty array: `accent` always resolves.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSegmentPalette(count: number): ThemeColor[] {
|
|
46
|
+
const palette: ThemeColor[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const color of SEGMENT_COLOR_CANDIDATES) {
|
|
49
|
+
const ansi = theme.getFgAnsi(color);
|
|
50
|
+
if (seen.has(ansi)) continue;
|
|
51
|
+
seen.add(ansi);
|
|
52
|
+
palette.push(color);
|
|
53
|
+
if (palette.length >= count) break;
|
|
54
|
+
}
|
|
55
|
+
return palette;
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
/**
|
|
23
59
|
* Render `segments` as a colored chip track with `activeIndex` filled. Returns
|
|
24
60
|
* a single line of styled text with no surrounding caption or arrows — callers
|
|
@@ -30,6 +66,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
|
|
|
30
66
|
const capLeft = theme.sep.powerlineRight;
|
|
31
67
|
const capRight = theme.sep.powerlineLeft;
|
|
32
68
|
const thinSep = theme.fg("statusLineSep", theme.sep.powerlineThin);
|
|
69
|
+
const palette = resolveSegmentPalette(segments.length);
|
|
33
70
|
|
|
34
71
|
let track = "";
|
|
35
72
|
segments.forEach((segment, i) => {
|
|
@@ -38,7 +75,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
|
|
|
38
75
|
// caps already delimit the active segment, so pad around it instead.
|
|
39
76
|
track += i === activeIndex || i - 1 === activeIndex ? " " : ` ${thinSep} `;
|
|
40
77
|
}
|
|
41
|
-
const color =
|
|
78
|
+
const color = palette[i % palette.length];
|
|
42
79
|
const fg = theme.getFgAnsi(color);
|
|
43
80
|
if (i !== activeIndex) {
|
|
44
81
|
track += `${fg}${segment.label}${FG_RESET}`;
|
|
@@ -18,6 +18,7 @@ import { renderSegment, type SegmentContext } from "./segments";
|
|
|
18
18
|
import { getSeparator } from "./separators";
|
|
19
19
|
import { calculateTokensPerSecond } from "./token-rate";
|
|
20
20
|
import type {
|
|
21
|
+
CollabStatus,
|
|
21
22
|
EffectiveStatusLineSettings,
|
|
22
23
|
StatusLineSegmentId,
|
|
23
24
|
StatusLineSegmentOptions,
|
|
@@ -152,6 +153,7 @@ export class StatusLineComponent implements Component {
|
|
|
152
153
|
#planModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
153
154
|
#loopModeStatus: { enabled: boolean } | null = null;
|
|
154
155
|
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
156
|
+
#collabStatus: CollabStatus | null = null;
|
|
155
157
|
|
|
156
158
|
// Git status caching (1s TTL)
|
|
157
159
|
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
@@ -217,6 +219,11 @@ export class StatusLineComponent implements Component {
|
|
|
217
219
|
this.#subagentCount = count;
|
|
218
220
|
}
|
|
219
221
|
|
|
222
|
+
/** Active subagent count as currently displayed (collab state mirroring). */
|
|
223
|
+
get subagentCount(): number {
|
|
224
|
+
return this.#subagentCount;
|
|
225
|
+
}
|
|
226
|
+
|
|
220
227
|
setSessionStartTime(time: number): void {
|
|
221
228
|
this.#sessionStartTime = time;
|
|
222
229
|
}
|
|
@@ -233,6 +240,10 @@ export class StatusLineComponent implements Component {
|
|
|
233
240
|
this.#goalModeStatus = status ?? null;
|
|
234
241
|
}
|
|
235
242
|
|
|
243
|
+
setCollabStatus(status: CollabStatus | null): void {
|
|
244
|
+
this.#collabStatus = status;
|
|
245
|
+
}
|
|
246
|
+
|
|
236
247
|
setHookStatus(key: string, text: string | undefined): void {
|
|
237
248
|
if (text === undefined) {
|
|
238
249
|
this.#hookStatuses.delete(key);
|
|
@@ -642,7 +653,15 @@ export class StatusLineComponent implements Component {
|
|
|
642
653
|
contextTokens = breakdown.usedTokens;
|
|
643
654
|
contextWindow = breakdown.contextWindow || contextWindow;
|
|
644
655
|
}
|
|
645
|
-
|
|
656
|
+
let contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
657
|
+
|
|
658
|
+
// Collab guest: context comes from the host's state frames — the local
|
|
659
|
+
// replica does no accounting of its own.
|
|
660
|
+
const collabState = this.#collabStatus?.stateOverride;
|
|
661
|
+
if (collabState?.contextUsage) {
|
|
662
|
+
contextWindow = collabState.contextUsage.contextWindow || contextWindow;
|
|
663
|
+
contextPercent = collabState.contextUsage.percent ?? contextPercent;
|
|
664
|
+
}
|
|
646
665
|
|
|
647
666
|
return {
|
|
648
667
|
session: this.session,
|
|
@@ -651,6 +670,7 @@ export class StatusLineComponent implements Component {
|
|
|
651
670
|
planMode: this.#planModeStatus,
|
|
652
671
|
loopMode: this.#loopModeStatus,
|
|
653
672
|
goalMode: this.#goalModeStatus,
|
|
673
|
+
collab: this.#collabStatus,
|
|
654
674
|
usageStats,
|
|
655
675
|
contextPercent,
|
|
656
676
|
contextWindow,
|
|
@@ -2,7 +2,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
|
|
|
2
2
|
|
|
3
3
|
export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
4
4
|
default: {
|
|
5
|
-
leftSegments: ["pi", "model", "mode", "path", "git", "pr", "context_pct", "cost"],
|
|
5
|
+
leftSegments: ["pi", "model", "mode", "collab", "path", "git", "pr", "context_pct", "cost"],
|
|
6
6
|
rightSegments: ["session_name"],
|
|
7
7
|
separator: "powerline-thin",
|
|
8
8
|
segmentOptions: {
|