@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/cli.js +363 -356
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +12 -0
  5. package/dist/types/collab/guest.d.ts +21 -0
  6. package/dist/types/collab/host.d.ts +13 -0
  7. package/dist/types/collab/protocol.d.ts +100 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +21 -1
  11. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  12. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  13. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  15. package/dist/types/modes/components/segment-track.d.ts +11 -6
  16. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  17. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  18. package/dist/types/modes/interactive-mode.d.ts +7 -0
  19. package/dist/types/modes/types.d.ts +8 -0
  20. package/dist/types/session/agent-session.d.ts +11 -0
  21. package/dist/types/session/session-manager.d.ts +21 -0
  22. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  23. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  24. package/package.json +14 -12
  25. package/scripts/bench-guard.ts +71 -0
  26. package/src/cli/args.ts +2 -0
  27. package/src/cli-commands.ts +1 -0
  28. package/src/collab/crypto.ts +57 -0
  29. package/src/collab/guest.ts +421 -0
  30. package/src/collab/host.ts +494 -0
  31. package/src/collab/protocol.ts +191 -0
  32. package/src/collab/relay-client.ts +216 -0
  33. package/src/commands/join.ts +39 -0
  34. package/src/config/model-registry.ts +22 -14
  35. package/src/config/settings-schema.ts +27 -1
  36. package/src/extensibility/slash-commands.ts +1 -97
  37. package/src/internal-urls/docs-index.generated.ts +3 -2
  38. package/src/main.ts +11 -2
  39. package/src/modes/components/agent-hub.ts +119 -22
  40. package/src/modes/components/assistant-message.ts +126 -6
  41. package/src/modes/components/collab-prompt-message.ts +30 -0
  42. package/src/modes/components/hook-selector.ts +4 -5
  43. package/src/modes/components/segment-track.ts +44 -7
  44. package/src/modes/components/status-line/component.ts +21 -1
  45. package/src/modes/components/status-line/presets.ts +1 -1
  46. package/src/modes/components/status-line/segments.ts +13 -0
  47. package/src/modes/components/status-line/types.ts +10 -0
  48. package/src/modes/components/tips.txt +2 -1
  49. package/src/modes/controllers/input-controller.ts +72 -6
  50. package/src/modes/controllers/selector-controller.ts +2 -0
  51. package/src/modes/controllers/streaming-reveal.ts +7 -0
  52. package/src/modes/interactive-mode.ts +12 -4
  53. package/src/modes/types.ts +8 -0
  54. package/src/modes/utils/ui-helpers.ts +7 -0
  55. package/src/sdk.ts +239 -36
  56. package/src/session/agent-session.ts +17 -0
  57. package/src/session/session-manager.ts +44 -0
  58. package/src/session/snapcompact-inline.ts +9 -3
  59. package/src/slash-commands/builtin-registry.ts +210 -0
  60. package/src/tools/read.ts +38 -5
  61. 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 (ref?.sessionFile) {
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
- contentLines.push(theme.fg("dim", "No messages yet."));
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: sessionFile, bytesRead: 0, entries: [] };
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
- if (result.text.length > 0) {
1032
- const lastNewline = result.text.lastIndexOf("\n");
1033
- if (lastNewline >= 0) {
1034
- const completeChunk = result.text.slice(0, lastNewline + 1);
1035
- const newEntries = parseSessionEntries(completeChunk);
1036
- for (const entry of newEntries) {
1037
- if (entry.type === "message") {
1038
- this.#transcriptCache.entries.push(entry);
1039
- // Extract model from first assistant message
1040
- const msg = entry.message;
1041
- if (!this.#transcriptCache.model && msg.role === "assistant") {
1042
- this.#transcriptCache.model = msg.model;
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
- this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
1049
- }
1050
- }
1051
- return this.#transcriptCache.entries;
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 markdown = new Markdown(content.text.trim(), 1, 0, getMarkdownTheme());
253
- markdown.transientRenderCache = this.#lastUpdateTransient;
254
- this.#contentContainer.addChild(markdown);
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 thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
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
- thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
273
- this.#contentContainer.addChild(thinkingMarkdown);
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, its accent color, and
35
- * an optional detail line (e.g. the resolved model name) shown beneath the
36
- * track while the segment is active. */
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 shows in its own accent, the active one is filled
4
- * as a powerline chip (its accent as the background, a luminance-matched label,
5
- * flanked by triangle caps) and the rest are plain colored labels joined by a
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 = segment.color ?? "accent";
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
- const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
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: {