@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.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 (82) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli.js +4967 -4948
  3. package/dist/types/config/model-registry.d.ts +1 -1
  4. package/dist/types/config/model-resolver.d.ts +5 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -0
  6. package/dist/types/dap/client.d.ts +19 -0
  7. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  9. package/dist/types/extensibility/typebox.d.ts +4 -0
  10. package/dist/types/hindsight/client.d.ts +10 -0
  11. package/dist/types/hindsight/config.d.ts +8 -0
  12. package/dist/types/mcp/tool-bridge.d.ts +9 -0
  13. package/dist/types/mnemopi/state.d.ts +2 -0
  14. package/dist/types/modes/components/user-message.d.ts +25 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +7 -1
  17. package/dist/types/modes/types.d.ts +14 -1
  18. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  19. package/dist/types/sdk.d.ts +1 -0
  20. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  21. package/dist/types/session/agent-session.d.ts +78 -2
  22. package/dist/types/task/executor.d.ts +10 -0
  23. package/dist/types/task/index.d.ts +1 -0
  24. package/dist/types/task/prewalk.d.ts +3 -0
  25. package/dist/types/tools/ask.d.ts +10 -0
  26. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  27. package/dist/types/utils/changelog.d.ts +4 -6
  28. package/package.json +12 -13
  29. package/src/cli/models-cli.ts +37 -17
  30. package/src/cli/update-cli.ts +14 -1
  31. package/src/config/model-registry.ts +47 -24
  32. package/src/config/model-resolver.ts +56 -31
  33. package/src/config/settings-schema.ts +5 -0
  34. package/src/config/settings.ts +68 -5
  35. package/src/dap/client.ts +86 -7
  36. package/src/edit/diff.ts +4 -4
  37. package/src/eval/py/prelude.py +5 -5
  38. package/src/export/html/template.js +23 -9
  39. package/src/extensibility/extensions/runner.ts +9 -4
  40. package/src/extensibility/extensions/types.ts +3 -0
  41. package/src/extensibility/typebox.ts +22 -9
  42. package/src/hindsight/client.test.ts +42 -0
  43. package/src/hindsight/client.ts +40 -3
  44. package/src/hindsight/config.ts +18 -0
  45. package/src/launch/broker.ts +31 -5
  46. package/src/lsp/index.ts +1 -1
  47. package/src/main.ts +15 -5
  48. package/src/mcp/tool-bridge.ts +9 -0
  49. package/src/mnemopi/state.ts +70 -3
  50. package/src/modes/components/agent-dashboard.ts +6 -2
  51. package/src/modes/components/chat-transcript-builder.ts +13 -2
  52. package/src/modes/components/diff.ts +2 -2
  53. package/src/modes/components/plan-review-overlay.ts +20 -4
  54. package/src/modes/components/user-message.ts +100 -1
  55. package/src/modes/controllers/command-controller.ts +22 -5
  56. package/src/modes/controllers/event-controller.ts +4 -1
  57. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  58. package/src/modes/controllers/input-controller.ts +47 -12
  59. package/src/modes/controllers/selector-controller.ts +82 -6
  60. package/src/modes/interactive-mode.ts +36 -4
  61. package/src/modes/types.ts +16 -3
  62. package/src/modes/utils/ui-helpers.ts +40 -20
  63. package/src/prompts/system/workflow-notice.md +1 -1
  64. package/src/sdk.ts +134 -48
  65. package/src/session/agent-session-error-log.test.ts +59 -0
  66. package/src/session/agent-session.ts +300 -26
  67. package/src/system-prompt.ts +1 -2
  68. package/src/task/executor.ts +41 -27
  69. package/src/task/index.ts +11 -0
  70. package/src/task/prewalk.ts +6 -0
  71. package/src/task/worktree.ts +25 -11
  72. package/src/tools/ask.ts +15 -0
  73. package/src/tools/bash.ts +14 -6
  74. package/src/tools/browser.ts +1 -1
  75. package/src/tools/debug.ts +1 -1
  76. package/src/tools/eval.ts +4 -5
  77. package/src/tools/fetch.ts +1 -1
  78. package/src/tools/hub/launch.ts +7 -0
  79. package/src/tools/tool-timeouts.ts +10 -3
  80. package/src/tools/write.ts +31 -0
  81. package/src/utils/changelog.ts +54 -58
  82. package/src/utils/git.ts +57 -49
@@ -172,6 +172,7 @@ export class InputController {
172
172
 
173
173
  #enhancedPaste?: EnhancedPasteController;
174
174
  #focusedLeftTapListenerInstalled = false;
175
+ #focusedPasteListenerInstalled = false;
175
176
  #btwBranchListenerInstalled = false;
176
177
  #btwCopyListenerInstalled = false;
177
178
  // Tap counter for the double-← gesture; reset whenever a quiet gap
@@ -265,6 +266,16 @@ export class InputController {
265
266
  return { consume: true };
266
267
  });
267
268
  }
269
+ if (!this.#focusedPasteListenerInstalled) {
270
+ this.#focusedPasteListenerInstalled = true;
271
+ this.ctx.ui.addInputListener(data => {
272
+ const focused = this.ctx.ui.getFocused();
273
+ if (!focused || focused === this.ctx.editor || !hasPasteText(focused)) return undefined;
274
+ if (!this.ctx.keybindings.matches(data, "app.clipboard.pasteImage")) return undefined;
275
+ void this.handleImagePaste();
276
+ return { consume: true };
277
+ });
278
+ }
268
279
  this.ctx.editor.onEscape = () => {
269
280
  // Side-channel panels are the topmost view. Esc dismisses them before
270
281
  // touching loop mode, maintenance, or the underlying main turn.
@@ -308,6 +319,14 @@ export class InputController {
308
319
  if (aborted) return;
309
320
  }
310
321
 
322
+ if (vocalizer.isSpeaking()) {
323
+ // Playback from the completed response can overlap the next agent
324
+ // turn. Silence it before interrupting any ongoing main-turn work.
325
+ vocalizer.clear();
326
+ this.ctx.lastEscapeTime = 0;
327
+ return;
328
+ }
329
+
311
330
  if (this.ctx.loopModeEnabled) {
312
331
  this.ctx.pauseLoop();
313
332
  if (this.ctx.session.isStreaming) {
@@ -360,13 +379,6 @@ export class InputController {
360
379
  } else if (this.ctx.editor.getText().trim()) {
361
380
  // Esc must not destroy an in-progress draft.
362
381
  this.ctx.lastEscapeTime = 0;
363
- } else if (vocalizer.isSpeaking()) {
364
- // TTS buffers seconds of PCM past the streaming abort, so an Esc
365
- // arriving after the model stopped would otherwise fall through to
366
- // the double-Esc gesture while Kokoro reads on. Silence first;
367
- // tree/branch stays reachable via a second Esc.
368
- vocalizer.clear();
369
- this.ctx.lastEscapeTime = 0;
370
382
  } else {
371
383
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
372
384
  const action = settings.get("doubleEscapeAction");
@@ -822,12 +834,25 @@ export class InputController {
822
834
  // First, move any pending bash components to chat
823
835
  this.ctx.flushPendingBashComponents();
824
836
 
837
+ // AgentSession.prompt() consumes registered extension commands locally.
838
+ // Classify them here because title generation starts before prompt dispatch.
839
+ const extensionCommandSpace = text.indexOf(" ");
840
+ const isLocalExtensionCommand =
841
+ text.startsWith("/") &&
842
+ runner?.getCommand(extensionCommandSpace === -1 ? text.slice(1) : text.slice(1, extensionCommandSpace)) !==
843
+ undefined;
844
+
825
845
  // Auto-generate a session title while the session is still unnamed.
826
846
  // Greetings / acknowledgements / empty input carry no task, so they are
827
847
  // skipped deterministically (no model invoked, no download-progress UI)
828
848
  // and the session stays unnamed — the next user message gets a fresh
829
849
  // chance, so titling defers past "hi" instead of latching onto it.
830
- if (!this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE && !isLowSignalTitleInput(text)) {
850
+ if (
851
+ !isLocalExtensionCommand &&
852
+ !this.ctx.sessionManager.getSessionName() &&
853
+ !$env.PI_NO_TITLE &&
854
+ !isLowSignalTitleInput(text)
855
+ ) {
831
856
  this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel"));
832
857
  this.ctx.session
833
858
  .generateTitle(text)
@@ -1558,8 +1583,19 @@ export class InputController {
1558
1583
 
1559
1584
  async handleImagePaste(): Promise<boolean> {
1560
1585
  try {
1586
+ // When a modal paste-capable prompt (login/API-key Input) owns focus,
1587
+ // only clipboard text may land there. Image payloads must not mutate
1588
+ // the hidden main editor — mirror the enhanced-paste `pasteImage`
1589
+ // behavior and surface the unsupported-status instead (#6057).
1590
+ const focusedNow = this.ctx.ui.getFocused();
1591
+ const promptTarget =
1592
+ focusedNow && focusedNow !== this.ctx.editor && hasPasteText(focusedNow) ? focusedNow : null;
1561
1593
  const image = await this.clipboard.readImage();
1562
1594
  if (image) {
1595
+ if (promptTarget) {
1596
+ this.ctx.showStatus("Image paste is not supported in this prompt");
1597
+ return false;
1598
+ }
1563
1599
  return await this.#normalizeAndInsertPastedImage(
1564
1600
  {
1565
1601
  type: "image",
@@ -1581,7 +1617,7 @@ export class InputController {
1581
1617
  // selections must not silently drop after the first attach.
1582
1618
  // `readMacFileUrls` returns an empty list off Darwin, so the
1583
1619
  // check is free on every other platform.
1584
- const fileUrls = (await this.clipboard.readMacFileUrls?.()) ?? [];
1620
+ const fileUrls = promptTarget ? [] : ((await this.clipboard.readMacFileUrls?.()) ?? []);
1585
1621
  let attachedFromFileUrls = false;
1586
1622
  for (const url of fileUrls) {
1587
1623
  const candidate = extractImagePathFromText(url);
@@ -1606,15 +1642,14 @@ export class InputController {
1606
1642
  // text. Covers terminals that paste the Finder file path as
1607
1643
  // plain text rather than as a `public.file-url` (most macOS
1608
1644
  // terminals do this for image clipboards).
1609
- const imagePath = extractImagePathFromText(text);
1645
+ const imagePath = promptTarget ? null : extractImagePathFromText(text);
1610
1646
  if (imagePath) {
1611
1647
  await this.handleImagePathPaste(imagePath);
1612
1648
  return true;
1613
1649
  }
1614
1650
  // Route to the focused component when it accepts pastes (modal
1615
1651
  // Input prompts), matching the enhanced-paste text path (#2127).
1616
- const focused = this.ctx.ui.getFocused();
1617
- const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1652
+ const target = promptTarget ?? this.ctx.editor;
1618
1653
  target.pasteText(text);
1619
1654
  this.ctx.ui.requestRender();
1620
1655
  return true;
@@ -1,4 +1,4 @@
1
- import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
1
+ import { type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
3
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
4
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
@@ -63,8 +63,11 @@ import {
63
63
  setExcludedSearchProviders,
64
64
  setPreferredImageProvider,
65
65
  setPreferredSearchProvider,
66
+ type ToolSession,
66
67
  } from "../../tools";
68
+ import { AskTool, type AskToolDetails, type AskToolInput } from "../../tools/ask";
67
69
  import { shortenPath } from "../../tools/render-utils";
70
+ import { ToolAbortError } from "../../tools/tool-errors";
68
71
  import { copyToClipboard } from "../../utils/clipboard";
69
72
  import { repo } from "../../utils/git";
70
73
  import { setSessionTerminalTitle } from "../../utils/title-generator";
@@ -1138,11 +1141,21 @@ export class SelectorController {
1138
1141
  realLeafId,
1139
1142
  this.ctx.ui.terminal.rows,
1140
1143
  async (entryId, options) => {
1141
- // Selecting the current leaf is a no-op (already there)
1144
+ // Selecting the current leaf is normally a no-op (already there)
1145
+ // unless it's an `ask` toolResult, in which case the re-answer flow
1146
+ // must still be allowed to reopen the picker even though the leaf
1147
+ // doesn't move (chatgpt-codex review on #5895).
1142
1148
  if (entryId === realLeafId) {
1143
- done();
1144
- this.ctx.showStatus("Already at this point");
1145
- return;
1149
+ const currentEntry = this.ctx.sessionManager.getEntry(entryId);
1150
+ const currentIsAskResult =
1151
+ currentEntry?.type === "message" &&
1152
+ currentEntry.message.role === "toolResult" &&
1153
+ currentEntry.message.toolName === "ask";
1154
+ if (!currentIsAskResult) {
1155
+ done();
1156
+ this.ctx.showStatus("Already at this point");
1157
+ return;
1158
+ }
1146
1159
  }
1147
1160
 
1148
1161
  // Ask about summarization
@@ -1204,11 +1217,29 @@ export class SelectorController {
1204
1217
  }
1205
1218
 
1206
1219
  try {
1207
- const result = await this.ctx.session.navigateTree(entryId, {
1220
+ let result = await this.ctx.session.navigateTree(entryId, {
1208
1221
  summarize: wantsSummary,
1209
1222
  customInstructions,
1223
+ allowAskReopen: true,
1210
1224
  });
1211
1225
 
1226
+ // Selecting an `ask` toolResult doesn't land the leaf directly —
1227
+ // re-open the picker with the original questions first, then
1228
+ // complete the navigation as a new sibling branch (issue #5642).
1229
+ if (result.reopenAsk) {
1230
+ const reanswer = await this.#reanswerAsk(result.reopenAsk.questions);
1231
+ if (!reanswer) {
1232
+ this.ctx.showStatus("Re-answer cancelled");
1233
+ return;
1234
+ }
1235
+ result = await this.ctx.session.navigateTree(entryId, {
1236
+ summarize: wantsSummary,
1237
+ customInstructions,
1238
+ allowAskReopen: true,
1239
+ reanswerAskResult: reanswer,
1240
+ });
1241
+ }
1242
+
1212
1243
  if (result.aborted) {
1213
1244
  // Summarization aborted - re-show tree selector
1214
1245
  this.ctx.showStatus("Branch summarization cancelled");
@@ -1252,6 +1283,51 @@ export class SelectorController {
1252
1283
  });
1253
1284
  }
1254
1285
 
1286
+ /**
1287
+ * Re-open the `ask` picker with the original `questions` (issue #5642):
1288
+ * runs a standalone `AskTool.execute()` outside a normal agent turn,
1289
+ * reusing the same picker/dialog primitives a live `ask` tool call gets.
1290
+ * Returns `undefined` when the user cancels — mirrors `navigateTree`'s
1291
+ * cancellation contract instead of throwing.
1292
+ */
1293
+ async #reanswerAsk(questions: AskToolInput["questions"]): Promise<AgentToolResult<AskToolDetails> | undefined> {
1294
+ const uiContext = this.ctx.getToolUIContext();
1295
+ if (!uiContext) {
1296
+ this.ctx.showError("Ask tool UI is not ready");
1297
+ return undefined;
1298
+ }
1299
+ const toolSession: ToolSession = {
1300
+ cwd: this.ctx.sessionManager.getCwd(),
1301
+ hasUI: true,
1302
+ settings: this.ctx.settings,
1303
+ getSessionFile: () => this.ctx.sessionManager.getSessionFile() ?? null,
1304
+ getSessionSpawns: () => null,
1305
+ getPlanModeState: () => this.ctx.session.getPlanModeState(),
1306
+ };
1307
+ const askTool = new AskTool(toolSession);
1308
+ const context = this.ctx.session.buildAskReanswerContext(uiContext);
1309
+ let result: AgentToolResult<AskToolDetails>;
1310
+ try {
1311
+ result = await askTool.execute("tree-reanswer", { questions }, undefined, undefined, context);
1312
+ } catch (error) {
1313
+ if (error instanceof ToolAbortError) return undefined;
1314
+ throw error;
1315
+ }
1316
+ // The rich ask dialog can race a collab guest choosing "Chat about this"
1317
+ // (`AskTool`'s `chatRedirect` result); that's meaningful inside a live
1318
+ // agent turn, where the model sees the redirect and starts a
1319
+ // conversation, but this standalone re-answer has no turn to hand it
1320
+ // to — completing the navigation with it would silently drop the
1321
+ // user's intent to chat (roboomp review on #5895).
1322
+ if (result.details?.chatRedirect) {
1323
+ this.ctx.showError(
1324
+ "Chat about this isn't available when re-answering from the tree — pick an option or type a custom answer instead.",
1325
+ );
1326
+ return undefined;
1327
+ }
1328
+ return result;
1329
+ }
1330
+
1255
1331
  async showSessionSelector(): Promise<void> {
1256
1332
  const sessions = await SessionManager.list(
1257
1333
  this.ctx.sessionManager.getCwd(),
@@ -481,6 +481,7 @@ export class InteractiveMode implements InteractiveModeContext {
481
481
  proseOnlyThinking = true;
482
482
  compactionQueuedMessages: CompactionQueuedMessage[] = [];
483
483
  pendingTools = new Map<string, ToolExecutionHandle>();
484
+ transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
484
485
  pendingBashComponents: BashExecutionComponent[] = [];
485
486
  bashComponent: BashExecutionComponent | undefined = undefined;
486
487
  pendingPythonComponents: EvalExecutionComponent[] = [];
@@ -553,6 +554,7 @@ export class InteractiveMode implements InteractiveModeContext {
553
554
  #planModeHasEntered = false;
554
555
  #planReviewOverlay: PlanReviewOverlay | undefined;
555
556
  #planReviewOverlayHandle: OverlayHandle | undefined;
557
+ #planReviewCancel: (() => void) | undefined;
556
558
  readonly lspServers: LspStartupServerInfo[] | undefined = undefined;
557
559
  mcpManager?: MCPManager;
558
560
  readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
@@ -1657,7 +1659,7 @@ export class InteractiveMode implements InteractiveModeContext {
1657
1659
  if (options.requestRender !== false) this.ui.requestRender();
1658
1660
  }
1659
1661
 
1660
- rebuildChatFromMessages(): void {
1662
+ rebuildChatFromMessages(options: { reuseSettledComponents?: boolean } = {}): void {
1661
1663
  // Mid-stream rebuilds (e.g. `/shake`, theme/setting changes that touch the
1662
1664
  // transcript) replay only committed `state.messages`. The agent's in-flight
1663
1665
  // `streamMessage` and its still-pending tool calls live OUTSIDE
@@ -1690,7 +1692,18 @@ export class InteractiveMode implements InteractiveModeContext {
1690
1692
  const context = this.viewSession.buildTranscriptSessionContext({
1691
1693
  collapseCompactedHistory: settings.get("display.collapseCompacted"),
1692
1694
  });
1693
- this.renderSessionContext(context);
1695
+ // Prune the settled-component cache to the messages this rebuild will
1696
+ // actually render. Message objects stay strongly reachable through
1697
+ // session entries for the whole session, so entries for compacted-away
1698
+ // history would otherwise pin their components' rendered layout caches
1699
+ // forever — exactly the memory a collapsed compaction used to release.
1700
+ const retained = new WeakMap<AgentMessage, Component>();
1701
+ for (const message of context.messages) {
1702
+ const component = this.transcriptMessageComponents.get(message);
1703
+ if (component) retained.set(message, component);
1704
+ }
1705
+ this.transcriptMessageComponents = retained;
1706
+ this.renderSessionContext(context, { reuseSettledComponents: options.reuseSettledComponents });
1694
1707
  for (const child of liveComponents) {
1695
1708
  this.chatContainer.addChild(child);
1696
1709
  }
@@ -2642,6 +2655,7 @@ export class InteractiveMode implements InteractiveModeContext {
2642
2655
  settled = true;
2643
2656
  resolve(choice);
2644
2657
  };
2658
+ this.#planReviewCancel = () => finish(undefined);
2645
2659
  const overlay = new PlanReviewOverlay(
2646
2660
  planContent,
2647
2661
  {
@@ -2678,11 +2692,19 @@ export class InteractiveMode implements InteractiveModeContext {
2678
2692
  }
2679
2693
 
2680
2694
  #hidePlanReview(): void {
2695
+ this.#planReviewCancel = undefined;
2681
2696
  this.#planReviewOverlayHandle?.hide();
2682
2697
  this.#planReviewOverlayHandle = undefined;
2683
2698
  this.#planReviewOverlay = undefined;
2684
2699
  }
2685
2700
 
2701
+ #dismissPlanReview(): void {
2702
+ const cancel = this.#planReviewCancel;
2703
+ this.#planReviewCancel = undefined;
2704
+ cancel?.();
2705
+ this.#hidePlanReview();
2706
+ }
2707
+
2686
2708
  #getEditorTerminalPath(): string | null {
2687
2709
  if (process.platform === "win32") {
2688
2710
  return null;
@@ -3912,6 +3934,7 @@ export class InteractiveMode implements InteractiveModeContext {
3912
3934
  }
3913
3935
 
3914
3936
  resetTranscript(): void {
3937
+ this.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
3915
3938
  this.chatContainer.dispose();
3916
3939
  this.chatContainer.clear();
3917
3940
  }
@@ -3931,6 +3954,7 @@ export class InteractiveMode implements InteractiveModeContext {
3931
3954
  }
3932
3955
 
3933
3956
  showPinnedError(message: string): void {
3957
+ this.#dismissPlanReview();
3934
3958
  this.errorBannerContainer.clear();
3935
3959
  this.errorBannerContainer.addChild(new ErrorBannerComponent(message));
3936
3960
  this.ui.requestRender();
@@ -4137,14 +4161,18 @@ export class InteractiveMode implements InteractiveModeContext {
4137
4161
 
4138
4162
  addMessageToChat(
4139
4163
  message: AgentMessage,
4140
- options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
4164
+ options?: {
4165
+ populateHistory?: boolean;
4166
+ imageLinks?: readonly (string | undefined)[];
4167
+ reuseSettledComponent?: boolean;
4168
+ },
4141
4169
  ): Component[] {
4142
4170
  return this.#uiHelpers.addMessageToChat(message, options);
4143
4171
  }
4144
4172
 
4145
4173
  renderSessionContext(
4146
4174
  sessionContext: SessionContext,
4147
- options?: { updateFooter?: boolean; populateHistory?: boolean },
4175
+ options?: { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean },
4148
4176
  ): void {
4149
4177
  for (const message of sessionContext.messages) {
4150
4178
  this.noteDisplayableThinkingContent(message);
@@ -4635,6 +4663,10 @@ export class InteractiveMode implements InteractiveModeContext {
4635
4663
  return this.#extensionUiController.initHooksAndCustomTools();
4636
4664
  }
4637
4665
 
4666
+ getToolUIContext(): ExtensionUIContext | undefined {
4667
+ return this.#extensionUiController.getToolUIContext();
4668
+ }
4669
+
4638
4670
  emitCustomToolSessionEvent(
4639
4671
  reason: "start" | "switch" | "branch" | "tree" | "shutdown",
4640
4672
  previousSessionFile?: string,
@@ -176,6 +176,8 @@ export interface InteractiveModeContext {
176
176
  noteDisplayableThinkingContent(message: AgentMessage): boolean;
177
177
  proseOnlyThinking: boolean;
178
178
  compactionQueuedMessages: CompactionQueuedMessage[];
179
+ /** Settled user/assistant components reusable across post-compaction transcript rebuilds. */
180
+ transcriptMessageComponents: WeakMap<AgentMessage, Component>;
179
181
  pendingTools: Map<string, ToolExecutionHandle>;
180
182
  pendingBashComponents: BashExecutionComponent[];
181
183
  bashComponent: BashExecutionComponent | undefined;
@@ -302,11 +304,15 @@ export interface InteractiveModeContext {
302
304
  isKnownSlashCommand(text: string): boolean;
303
305
  addMessageToChat(
304
306
  message: AgentMessage,
305
- options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
307
+ options?: {
308
+ populateHistory?: boolean;
309
+ imageLinks?: readonly (string | undefined)[];
310
+ reuseSettledComponent?: boolean;
311
+ },
306
312
  ): Component[];
307
313
  renderSessionContext(
308
314
  sessionContext: SessionContext,
309
- options?: { updateFooter?: boolean; populateHistory?: boolean },
315
+ options?: { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean },
310
316
  ): void;
311
317
  renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void;
312
318
  getUserMessageText(message: Message): string;
@@ -315,7 +321,7 @@ export interface InteractiveModeContext {
315
321
  /** Refresh the running-subagents status badge from the active local or collab registry. */
316
322
  syncRunningSubagentBadge(): void;
317
323
  updateEditorBorderColor(): void;
318
- rebuildChatFromMessages(): void;
324
+ rebuildChatFromMessages(options?: { reuseSettledComponents?: boolean }): void;
319
325
  setTodos(todos: TodoItem[] | TodoPhase[]): void;
320
326
  reloadTodos(): Promise<void>;
321
327
  toggleTodoExpansion(): void;
@@ -422,6 +428,13 @@ export interface InteractiveModeContext {
422
428
 
423
429
  // Hook UI methods
424
430
  initHooksAndCustomTools(): Promise<void>;
431
+ /**
432
+ * The live `ExtensionUIContext` (picker/dialog primitives) used for tool
433
+ * execution, `undefined` before hooks have initialized. `/tree` `ask`
434
+ * re-answer (issue #5642) reuses it to drive a standalone
435
+ * `AskTool.execute()` call.
436
+ */
437
+ getToolUIContext(): ExtensionUIContext | undefined;
425
438
  emitCustomToolSessionEvent(
426
439
  reason: "start" | "switch" | "branch" | "tree" | "shutdown",
427
440
  previousSessionFile?: string,
@@ -66,6 +66,17 @@ type QueuedMessages = {
66
66
  steering: string[];
67
67
  followUp: string[];
68
68
  };
69
+ type AddMessageOptions = {
70
+ populateHistory?: boolean;
71
+ imageLinks?: readonly (string | undefined)[];
72
+ reuseSettledComponent?: boolean;
73
+ };
74
+
75
+ type RenderSessionContextOptions = {
76
+ updateFooter?: boolean;
77
+ populateHistory?: boolean;
78
+ reuseSettledComponents?: boolean;
79
+ };
69
80
 
70
81
  function imageLinksForMessage(
71
82
  message: Extract<AgentMessage, { role: "developer" | "user" }>,
@@ -118,10 +129,7 @@ export class UiHelpers {
118
129
  this.ctx.lastStatusText = text;
119
130
  }
120
131
 
121
- addMessageToChat(
122
- message: AgentMessage,
123
- options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
124
- ): Component[] {
132
+ addMessageToChat(message: AgentMessage, options?: AddMessageOptions): Component[] {
125
133
  switch (message.role) {
126
134
  case "bashExecution": {
127
135
  const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext);
@@ -233,13 +241,22 @@ export class UiHelpers {
233
241
  const textContent = this.ctx.getUserMessageText(message);
234
242
  if (textContent) {
235
243
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
236
- const imageLinks =
237
- options?.imageLinks ??
238
- imageLinksForMessage(
239
- message,
240
- this.ctx.viewSession.sessionManager.putBlobSync.bind(this.ctx.viewSession.sessionManager),
241
- );
242
- const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
244
+ const cached = options?.reuseSettledComponent
245
+ ? this.ctx.transcriptMessageComponents.get(message)
246
+ : undefined;
247
+ let userComponent: UserMessageComponent;
248
+ if (cached instanceof UserMessageComponent) {
249
+ userComponent = cached;
250
+ } else {
251
+ const imageLinks =
252
+ options?.imageLinks ??
253
+ imageLinksForMessage(
254
+ message,
255
+ this.ctx.viewSession.sessionManager.putBlobSync.bind(this.ctx.viewSession.sessionManager),
256
+ );
257
+ userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
258
+ this.ctx.transcriptMessageComponents.set(message, userComponent);
259
+ }
243
260
  this.ctx.chatContainer.addChild(userComponent);
244
261
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
245
262
  this.ctx.editor.addToHistory(textContent);
@@ -248,10 +265,16 @@ export class UiHelpers {
248
265
  break;
249
266
  }
250
267
  case "assistant": {
251
- const assistantComponent = createAssistantMessageComponent(
252
- this.ctx,
253
- splitAssistantMessageToolTimeline(message).beforeTools,
254
- );
268
+ const cached = options?.reuseSettledComponent
269
+ ? this.ctx.transcriptMessageComponents.get(message)
270
+ : undefined;
271
+ const assistantComponent =
272
+ cached instanceof AssistantMessageComponent
273
+ ? cached
274
+ : createAssistantMessageComponent(this.ctx, splitAssistantMessageToolTimeline(message).beforeTools);
275
+ if (cached !== assistantComponent) {
276
+ this.ctx.transcriptMessageComponents.set(message, assistantComponent);
277
+ }
255
278
  this.ctx.chatContainer.addChild(assistantComponent);
256
279
  break;
257
280
  }
@@ -272,10 +295,7 @@ export class UiHelpers {
272
295
  * @param options.updateFooter Update footer state
273
296
  * @param options.populateHistory Add user messages to editor history
274
297
  */
275
- renderSessionContext(
276
- sessionContext: SessionContext,
277
- options: { updateFooter?: boolean; populateHistory?: boolean } = {},
278
- ): void {
298
+ renderSessionContext(sessionContext: SessionContext, options: RenderSessionContextOptions = {}): void {
279
299
  // Preserved: message_start handler owns this lifecycle (see #783)
280
300
  this.ctx.pendingTools.clear();
281
301
  // Reseed the cache-invalidation baseline: this rebuild re-derives every
@@ -362,7 +382,7 @@ export class UiHelpers {
362
382
  // Assistant messages need special handling for tool calls
363
383
  if (message.role === "assistant") {
364
384
  const timeline = splitAssistantMessageToolTimeline(message);
365
- this.ctx.addMessageToChat(message);
385
+ this.ctx.addMessageToChat(message, { reuseSettledComponent: options.reuseSettledComponents });
366
386
  const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1];
367
387
  const assistantComponent = lastChild instanceof AssistantMessageComponent ? lastChild : undefined;
368
388
  if (assistantComponent) {
@@ -13,7 +13,7 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
13
13
  <helpers>
14
14
  State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
15
15
 
16
- - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap): main agent depth = 0, each `agent()` child increments depth by 1, and, when the cap is non-negative, a spawner may call `agent()` only while its current `taskDepth < cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
16
+ - `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("scout", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes. Recursion follows `task.maxRecursionDepth` (default 2; a negative value disables the cap): main agent depth = 0, each `agent()` child increments depth by 1, and, when the cap is non-negative, a spawner may call `agent()` only while its current `taskDepth < cap`. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
17
17
  - `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
18
18
  - `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
19
19
  - `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.