@oh-my-pi/pi-coding-agent 15.11.6 → 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 (102) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/cli.js +431 -381
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/bench-cli.d.ts +78 -0
  5. package/dist/types/collab/crypto.d.ts +12 -0
  6. package/dist/types/collab/guest.d.ts +21 -0
  7. package/dist/types/collab/host.d.ts +13 -0
  8. package/dist/types/collab/protocol.d.ts +100 -0
  9. package/dist/types/collab/relay-client.d.ts +22 -0
  10. package/dist/types/commands/bench.d.ts +29 -0
  11. package/dist/types/commands/join.d.ts +12 -0
  12. package/dist/types/config/model-resolver.d.ts +3 -2
  13. package/dist/types/config/settings-schema.d.ts +93 -1
  14. package/dist/types/edit/renderer.d.ts +1 -0
  15. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  16. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  20. package/dist/types/modes/components/segment-track.d.ts +11 -6
  21. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  22. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  23. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  24. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  26. package/dist/types/modes/interactive-mode.d.ts +7 -0
  27. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  28. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  29. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  30. package/dist/types/modes/types.d.ts +8 -0
  31. package/dist/types/session/agent-session.d.ts +11 -0
  32. package/dist/types/session/session-manager.d.ts +21 -0
  33. package/dist/types/session/snapcompact-inline.d.ts +8 -3
  34. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  35. package/dist/types/tools/bash.d.ts +2 -0
  36. package/dist/types/tools/eval-render.d.ts +1 -0
  37. package/dist/types/tools/renderers.d.ts +13 -0
  38. package/dist/types/tools/ssh.d.ts +1 -0
  39. package/package.json +14 -12
  40. package/scripts/bench-guard.ts +71 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli/bench-cli.ts +437 -0
  43. package/src/cli-commands.ts +2 -0
  44. package/src/collab/crypto.ts +57 -0
  45. package/src/collab/guest.ts +421 -0
  46. package/src/collab/host.ts +494 -0
  47. package/src/collab/protocol.ts +191 -0
  48. package/src/collab/relay-client.ts +216 -0
  49. package/src/commands/bench.ts +42 -0
  50. package/src/commands/join.ts +39 -0
  51. package/src/config/model-registry.ts +74 -19
  52. package/src/config/model-resolver.ts +36 -5
  53. package/src/config/settings-schema.ts +119 -1
  54. package/src/edit/renderer.ts +5 -0
  55. package/src/extensibility/slash-commands.ts +1 -97
  56. package/src/hindsight/client.ts +26 -1
  57. package/src/hindsight/state.ts +6 -2
  58. package/src/internal-urls/docs-index.generated.ts +4 -3
  59. package/src/main.ts +11 -2
  60. package/src/mcp/transports/stdio.ts +81 -7
  61. package/src/modes/components/agent-hub.ts +119 -22
  62. package/src/modes/components/assistant-message.ts +126 -6
  63. package/src/modes/components/collab-prompt-message.ts +30 -0
  64. package/src/modes/components/hook-selector.ts +4 -5
  65. package/src/modes/components/oauth-selector.ts +67 -7
  66. package/src/modes/components/segment-track.ts +44 -7
  67. package/src/modes/components/settings-selector.ts +27 -0
  68. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  69. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  70. package/src/modes/components/status-line/component.ts +21 -1
  71. package/src/modes/components/status-line/presets.ts +1 -1
  72. package/src/modes/components/status-line/segments.ts +13 -0
  73. package/src/modes/components/status-line/types.ts +10 -0
  74. package/src/modes/components/tips.txt +2 -1
  75. package/src/modes/components/tool-execution.ts +18 -10
  76. package/src/modes/controllers/input-controller.ts +80 -12
  77. package/src/modes/controllers/selector-controller.ts +6 -2
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +36 -4
  80. package/src/modes/setup-wizard/index.ts +1 -0
  81. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  82. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  83. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  84. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  85. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  86. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  87. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  88. package/src/modes/types.ts +8 -0
  89. package/src/modes/utils/context-usage.ts +1 -1
  90. package/src/modes/utils/ui-helpers.ts +7 -0
  91. package/src/prompts/bench.md +7 -0
  92. package/src/sdk.ts +240 -36
  93. package/src/session/agent-session.ts +22 -0
  94. package/src/session/session-manager.ts +44 -0
  95. package/src/session/snapcompact-inline.ts +20 -22
  96. package/src/slash-commands/builtin-registry.ts +210 -0
  97. package/src/tools/bash.ts +3 -0
  98. package/src/tools/eval-render.ts +4 -0
  99. package/src/tools/read.ts +38 -5
  100. package/src/tools/renderers.ts +13 -0
  101. package/src/tools/ssh.ts +3 -0
  102. 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.
@@ -85,27 +85,99 @@ async function resolveWindowsCommandPath(
85
85
  env: Record<string, string | undefined>,
86
86
  ): Promise<string | null> {
87
87
  const extensions = getWindowsPathExt(env);
88
- if (hasExecutableExtension(command, extensions)) return command;
88
+ const hasExt = hasExecutableExtension(command, extensions);
89
+ const candidates = hasExt ? [command] : extensions.map(ext => `${command}${ext}`);
89
90
 
90
- const candidates = extensions.map(ext => `${command}${ext}`);
91
91
  if (hasPathSegment(command)) {
92
92
  for (const candidate of candidates) {
93
93
  const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
94
94
  if (await fileExists(resolved)) return resolved;
95
95
  }
96
- return null;
96
+ return hasExt ? command : null;
97
97
  }
98
98
 
99
+ // Match cmd.exe's lookup order for an unqualified name: current directory
100
+ // first, then PATH. Skipping cwd would launch a global shim instead of a
101
+ // project-local one with the same name.
102
+ const searchDirs = [cwd];
99
103
  const pathValue = getCaseInsensitiveEnv(env, "PATH");
100
- if (!pathValue) return null;
101
- for (const dir of pathValue.split(";")) {
102
- if (!dir) continue;
104
+ if (pathValue) {
105
+ for (const dir of pathValue.split(";")) {
106
+ if (dir) searchDirs.push(dir);
107
+ }
108
+ }
109
+ for (const dir of searchDirs) {
103
110
  for (const candidate of candidates) {
104
111
  const resolved = path.join(dir, candidate);
105
112
  if (await fileExists(resolved)) return resolved;
106
113
  }
107
114
  }
108
- return null;
115
+ return hasExt ? command : null;
116
+ }
117
+
118
+ function resolveWindowsShimPath(value: string, shimDir: string): string | null {
119
+ const match = /^%dp0%[\\/]*(.*)$/i.exec(value);
120
+ if (!match) return null;
121
+ const suffix = match[1];
122
+ if (!suffix) return shimDir;
123
+ return path.join(shimDir, ...suffix.split(/[\\/]+/).filter(Boolean));
124
+ }
125
+
126
+ function extractWindowsNpmShimTarget(content: string): string | null {
127
+ const match = /"%_prog%"\s+"([^"]+)"\s+%\*/i.exec(content);
128
+ return match?.[1] ?? null;
129
+ }
130
+
131
+ /**
132
+ * Extract the shim's PATH-fallback interpreter (`SET "_prog=node"`). The
133
+ * `IF EXIST` branch assigns a `%dp0%`-prefixed value, so requiring a
134
+ * non-`%`-leading value picks the bare program name.
135
+ */
136
+ function extractWindowsNpmShimProg(content: string): string | null {
137
+ const match = /SET\s+"_prog=([^%"][^"]*)"/i.exec(content);
138
+ return match?.[1] ?? null;
139
+ }
140
+
141
+ async function resolveWindowsNpmShimCommand(
142
+ command: string,
143
+ args: readonly string[],
144
+ cwd: string,
145
+ ): Promise<StdioSpawnCommand | null> {
146
+ if (!isWindowsBatchCommand(command)) return null;
147
+ if (!hasPathSegment(command)) return null;
148
+ const commandPath = path.resolve(cwd, command);
149
+
150
+ let content: string;
151
+ try {
152
+ content = await Bun.file(commandPath).text();
153
+ } catch {
154
+ return null;
155
+ }
156
+
157
+ // cmd-shim emits the same invocation line for every interpreter; only
158
+ // bypass cmd.exe when the shim's fallback interpreter is actually node.
159
+ const prog = extractWindowsNpmShimProg(content);
160
+ if (
161
+ !prog ||
162
+ path
163
+ .basename(prog)
164
+ .replace(/\.exe$/i, "")
165
+ .toLowerCase() !== "node"
166
+ )
167
+ return null;
168
+
169
+ const rawTarget = extractWindowsNpmShimTarget(content);
170
+ if (!rawTarget) return null;
171
+
172
+ const target = resolveWindowsShimPath(rawTarget, path.dirname(commandPath));
173
+ if (!target) return null;
174
+
175
+ const siblingNode = path.join(path.dirname(commandPath), "node.exe");
176
+ const nodeCommand = (await fileExists(siblingNode)) ? siblingNode : "node";
177
+ return {
178
+ cmd: [nodeCommand, target, ...args],
179
+ windowsHide: true,
180
+ };
109
181
  }
110
182
 
111
183
  function quoteCmdArg(value: string): string {
@@ -150,6 +222,8 @@ export async function resolveStdioSpawnCommand(
150
222
 
151
223
  const resolvedCommand =
152
224
  (await resolveWindowsCommandPath(config.command, options.cwd, options.env)) ?? config.command;
225
+ const npmShimCommand = await resolveWindowsNpmShimCommand(resolvedCommand, args, options.cwd);
226
+ if (npmShimCommand) return npmShimCommand;
153
227
  if (!isWindowsBatchCommand(resolvedCommand)) return { cmd: [resolvedCommand, ...args] };
154
228
 
155
229
  return {
@@ -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
  }