@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5

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 (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. package/src/web/search/providers/codex.ts +195 -99
@@ -3,7 +3,6 @@ import {
3
3
  Container,
4
4
  type NativeScrollbackCommittedRows,
5
5
  type NativeScrollbackLiveRegion,
6
- type NativeScrollbackReplay,
7
6
  type RenderStablePrefix,
8
7
  type ViewportTailProvider,
9
8
  } from "@oh-my-pi/pi-tui";
@@ -124,8 +123,6 @@ interface BlockSegment {
124
123
  sep: number;
125
124
  /** Whether the block reported finalized when this segment was rendered. */
126
125
  finalized: boolean;
127
- /** Safe to drop after commit: produced while finalized, without post-finalize version tracking. */
128
- compactable: boolean;
129
126
  /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
130
127
  version: number | undefined;
131
128
  }
@@ -161,12 +158,7 @@ const EMPTY_TAIL: readonly string[] = [];
161
158
  */
162
159
  export class TranscriptContainer
163
160
  extends Container
164
- implements
165
- NativeScrollbackLiveRegion,
166
- NativeScrollbackCommittedRows,
167
- NativeScrollbackReplay,
168
- RenderStablePrefix,
169
- ViewportTailProvider
161
+ implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
170
162
  {
171
163
  // Bumped to retire every block segment at once (theme change / clear); a
172
164
  // segment is only reused when its stored generation matches.
@@ -175,6 +167,7 @@ export class TranscriptContainer
175
167
  // final: the leading finalized blocks plus the first live block's declared
176
168
  // settled rows. TUI commits rows to native scrollback only above it.
177
169
  #nativeScrollbackLiveRegionStart: number | undefined;
170
+ #nativeScrollbackLiveRegionPinned = false;
178
171
  // Persistent assembled transcript rows. Rows before the stable floor are
179
172
  // byte-identical to the previous render; rows at/after it were re-pushed.
180
173
  #lines: string[] = [];
@@ -184,14 +177,6 @@ export class TranscriptContainer
184
177
  // Finalized blocks wholly before this boundary are immutable on-screen history;
185
178
  // their previous contribution can be replayed without calling render().
186
179
  #committedRows = 0;
187
- // Leading children whose rows were handed to native scrollback and dropped
188
- // from the local frame. Children remain owned by the session and can be
189
- // re-rendered when the TUI prepares a destructive full replay.
190
- #compactedChildStart = 0;
191
- // Suppresses re-compaction for the rehydrating render. The TUI feeds its old
192
- // committed-row count immediately before render, so resetting that count
193
- // alone cannot distinguish a replay from an ordinary update.
194
- #replayPending = false;
195
180
  // Stable-prefix floor accumulated across renders since the last
196
181
  // getRenderStablePrefixRows() read (see RenderStablePrefix: reading
197
182
  // consumes the report and re-bases the baseline). Out-of-band renders
@@ -207,14 +192,12 @@ export class TranscriptContainer
207
192
  override clear(): void {
208
193
  this.#generation++;
209
194
  super.clear();
210
- this.#compactedChildStart = 0;
211
195
  this.#committedRows = 0;
212
- this.#replayPending = false;
213
196
  }
214
197
 
215
198
  override setNativeScrollbackCommittedRows(rows: number): void {
216
199
  this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
217
- for (let i = this.#compactedChildStart; i < this.children.length; i++) {
200
+ for (let i = 0; i < this.children.length; i++) {
218
201
  const child = this.children[i]!;
219
202
  const segment = this.#segments[i];
220
203
  if (segment === undefined || segment.component !== child) continue;
@@ -237,18 +220,6 @@ export class TranscriptContainer
237
220
  }
238
221
  }
239
222
 
240
- override prepareNativeScrollbackReplay(): void {
241
- // Replay retires the old terminal tape, so descendants may discard layout
242
- // locks whose only purpose was keeping that immutable history byte-stable.
243
- super.prepareNativeScrollbackReplay();
244
- if (this.#compactedChildStart === 0) return;
245
- this.#compactedChildStart = 0;
246
- this.#replayPending = true;
247
- this.#generation++;
248
- this.#lines.length = 0;
249
- this.#stableRowsFloor = 0;
250
- }
251
-
252
223
  getRenderStablePrefixRows(): number {
253
224
  const value = Math.min(this.#stableRowsFloor, this.#lines.length);
254
225
  this.#stableRowsFloor = this.#lines.length;
@@ -259,6 +230,11 @@ export class TranscriptContainer
259
230
  return this.#nativeScrollbackLiveRegionStart;
260
231
  }
261
232
 
233
+ /** Propagates viewport pinning from the first still-mutating transcript block. */
234
+ isNativeScrollbackLiveRegionPinned(): boolean {
235
+ return this.#nativeScrollbackLiveRegionPinned;
236
+ }
237
+
262
238
  /**
263
239
  * Whether none of `component`'s rows (per the most recent render) have
264
240
  * entered native scrollback. Callers that retract ephemeral blocks (IRC
@@ -269,14 +245,8 @@ export class TranscriptContainer
269
245
  * committed rows and is safely removable.
270
246
  */
271
247
  isBlockUncommitted(component: Component): boolean {
272
- const index = this.children.indexOf(component);
273
- // Compacted prefix is already committed native history and must not be
274
- // retracted. Compacted slots may be sparse holes after a later re-render
275
- // (render only fills from #compactedChildStart), so the loop below must
276
- // skip undefined entries.
277
- if (index >= 0 && index < this.#compactedChildStart) return false;
278
248
  for (const segment of this.#segments) {
279
- if (segment === undefined || segment.component !== component) continue;
249
+ if (segment.component !== component) continue;
280
250
  return segment.rowCount === 0 || segment.startRow >= this.#committedRows;
281
251
  }
282
252
  return true;
@@ -330,7 +300,7 @@ export class TranscriptContainer
330
300
  if (maxRows <= 0) return EMPTY_TAIL;
331
301
  const collected: (readonly string[])[] = [];
332
302
  let total = 0;
333
- for (let i = this.children.length - 1; i >= this.#compactedChildStart && total < maxRows; i--) {
303
+ for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) {
334
304
  const contribution = stripPlainBlankEdges(this.children[i]!.render(width));
335
305
  if (contribution.length === 0) continue;
336
306
  // One blank separator sits between this block and the (already
@@ -352,9 +322,9 @@ export class TranscriptContainer
352
322
  override render(width: number): readonly string[] {
353
323
  width = Math.max(1, width);
354
324
  this.#nativeScrollbackLiveRegionStart = undefined;
325
+ this.#nativeScrollbackLiveRegionPinned = false;
355
326
 
356
327
  const count = this.children.length;
357
- if (this.#compactedChildStart > count) this.#compactedChildStart = count;
358
328
 
359
329
  // Seal displaceable snapshots whose rows are already on the tape (per the
360
330
  // previous frame's segments — the geometry the committed count was
@@ -364,7 +334,7 @@ export class TranscriptContainer
364
334
  // frame, and every frame so a block that BECAME displaceable after its
365
335
  // pending-preview rows committed (late result on a scrolled-off call) is
366
336
  // caught too.
367
- for (let i = this.#compactedChildStart; i < count && i < this.#segments.length; i++) {
337
+ for (let i = 0; i < count && i < this.#segments.length; i++) {
368
338
  const previous = this.#segments[i];
369
339
  if (previous === undefined) continue;
370
340
  if (previous.startRow >= this.#committedRows) break;
@@ -380,10 +350,14 @@ export class TranscriptContainer
380
350
  // reaches.
381
351
  let liveStartIndex = -1;
382
352
  let hasLiveBlock = false;
383
- for (let i = this.#compactedChildStart; i < count; i++) {
353
+ for (let i = 0; i < count; i++) {
384
354
  if (!isBlockFinalized(this.children[i]!)) {
385
355
  liveStartIndex = i;
386
356
  hasLiveBlock = true;
357
+ this.#nativeScrollbackLiveRegionPinned =
358
+ (
359
+ this.children[i] as Component & Partial<NativeScrollbackLiveRegion>
360
+ ).isNativeScrollbackLiveRegionPinned?.() === true;
387
361
  break;
388
362
  }
389
363
  }
@@ -412,7 +386,7 @@ export class TranscriptContainer
412
386
  // Frame row cursor: rows emitted (reused or pushed) so far.
413
387
  let row = 0;
414
388
  let stableRows = 0;
415
- for (let i = this.#compactedChildStart; i < count; i++) {
389
+ for (let i = 0; i < count; i++) {
416
390
  const child = this.children[i]!;
417
391
 
418
392
  // This child's contribution: its current render with plain-blank
@@ -453,7 +427,6 @@ export class TranscriptContainer
453
427
  previous.width === width &&
454
428
  previous.generation === this.#generation);
455
429
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
456
- const compactable = finalized && version === undefined && previous?.finalized !== false;
457
430
 
458
431
  // Empty (or stripped-to-nothing) children contribute nothing and never
459
432
  // affect spacing. An empty still-live child still gates the commit
@@ -478,7 +451,6 @@ export class TranscriptContainer
478
451
  rowCount: 0,
479
452
  sep: 0,
480
453
  finalized,
481
- compactable,
482
454
  version,
483
455
  };
484
456
  continue;
@@ -530,7 +502,6 @@ export class TranscriptContainer
530
502
  rowCount,
531
503
  sep,
532
504
  finalized,
533
- compactable,
534
505
  version,
535
506
  };
536
507
  row += rowCount;
@@ -540,72 +511,8 @@ export class TranscriptContainer
540
511
  if (lines.length !== row) lines.length = row;
541
512
  this.#segments = segments;
542
513
  this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
543
- if (this.#replayPending) {
544
- this.#replayPending = false;
545
- } else {
546
- this.#compactCommittedPrefix();
547
- }
548
514
  return lines;
549
515
  }
550
-
551
- #compactCommittedPrefix(): void {
552
- if (this.#committedRows <= 0 || this.#compactedChildStart >= this.children.length) return;
553
- const lines = this.#lines;
554
- const segments = this.#segments;
555
- let dropRows = 0;
556
- let dropUntil = this.#compactedChildStart;
557
- for (let i = this.#compactedChildStart; i < segments.length; i++) {
558
- const segment = segments[i];
559
- if (segment === undefined || !segment.compactable) break;
560
- const segmentEnd = segment.startRow + segment.rowCount;
561
- if (segmentEnd > this.#committedRows) break;
562
- dropRows = segmentEnd;
563
- dropUntil = i + 1;
564
- }
565
- const retained = segments[dropUntil];
566
- if (retained !== undefined && retained.sep > 0) {
567
- const committedSeparatorRows = this.#committedRows - retained.startRow;
568
- if (committedSeparatorRows <= 0) {
569
- dropRows = 0;
570
- dropUntil = this.#compactedChildStart;
571
- } else {
572
- const trim = Math.min(retained.sep, committedSeparatorRows);
573
- dropRows += trim;
574
- retained.sep -= trim;
575
- retained.rowCount -= trim;
576
- }
577
- }
578
- if (dropRows === 0) return;
579
-
580
- lines.splice(0, dropRows);
581
- for (let i = this.#compactedChildStart; i < dropUntil; i++) {
582
- const segment = segments[i];
583
- if (segment === undefined) continue;
584
- segments[i] = {
585
- component: segment.component,
586
- rawRef: EMPTY_TAIL,
587
- contribution: EMPTY_TAIL,
588
- width: segment.width,
589
- generation: segment.generation,
590
- startRow: 0,
591
- rowCount: 0,
592
- sep: 0,
593
- finalized: true,
594
- compactable: false,
595
- version: segment.version,
596
- };
597
- }
598
- for (let i = dropUntil; i < segments.length; i++) {
599
- const segment = segments[i];
600
- if (segment !== undefined) segment.startRow -= dropRows;
601
- }
602
- this.#compactedChildStart = dropUntil;
603
- this.#committedRows = Math.max(0, this.#committedRows - dropRows);
604
- this.#stableRowsFloor = 0;
605
- if (this.#nativeScrollbackLiveRegionStart !== undefined) {
606
- this.#nativeScrollbackLiveRegionStart = Math.max(0, this.#nativeScrollbackLiveRegionStart - dropRows);
607
- }
608
- }
609
516
  }
610
517
 
611
518
  /**
@@ -66,7 +66,7 @@ class TreeList implements Component {
66
66
  #activePathIds: Set<string> = new Set();
67
67
  #lastSelectedId: string | null = null;
68
68
 
69
- onSelect?: (entryId: string) => void;
69
+ onSelect?: (entryId: string, options: { summarize: boolean }) => void;
70
70
  onCancel?: () => void;
71
71
  onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
72
72
 
@@ -792,10 +792,16 @@ class TreeList implements Component {
792
792
  } else if (matchesKey(keyData, "right")) {
793
793
  // Page down
794
794
  this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines);
795
+ } else if (matchesKey(keyData, "shift+enter") || matchesKey(keyData, "shift+return")) {
796
+ // Summarize-and-switch: fork with a branch summary without the extra prompt.
797
+ const selected = this.#filteredNodes[this.#selectedIndex];
798
+ if (selected && this.onSelect) {
799
+ this.onSelect(selected.node.entry.id, { summarize: true });
800
+ }
795
801
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
796
802
  const selected = this.#filteredNodes[this.#selectedIndex];
797
803
  if (selected && this.onSelect) {
798
- this.onSelect(selected.node.entry.id);
804
+ this.onSelect(selected.node.entry.id, { summarize: false });
799
805
  }
800
806
  } else if (matchesAppInterrupt(keyData)) {
801
807
  if (this.#searchQuery) {
@@ -923,7 +929,7 @@ export class TreeSelectorComponent extends Container {
923
929
  tree: SessionTreeNode[],
924
930
  currentLeafId: string | null,
925
931
  terminalHeight: number,
926
- onSelect: (entryId: string) => void,
932
+ onSelect: (entryId: string, options: { summarize: boolean }) => void,
927
933
  onCancel: () => void,
928
934
  private readonly onLabelChangeCallback?: (entryId: string, label: string | undefined) => void,
929
935
  initialFilterMode: FilterMode = "default",
@@ -948,7 +954,7 @@ export class TreeSelectorComponent extends Container {
948
954
  new TruncatedText(
949
955
  theme.fg(
950
956
  "muted",
951
- "Up/Down: move. Left/Right: page. Shift+L: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
957
+ "Enter: switch. Shift+Enter: summarize & switch. Shift+L: label. Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
952
958
  ),
953
959
  0,
954
960
  0,
@@ -247,7 +247,6 @@ export class EventController {
247
247
  toolCallId: string,
248
248
  result: { content: Array<{ type: string; data?: string; mimeType?: string }> },
249
249
  ): boolean {
250
- if (!settings.get("terminal.showImages")) return false;
251
250
  const assistantComponent = this.#readToolCallAssistantComponents.get(toolCallId);
252
251
  if (!assistantComponent) return false;
253
252
  const images: ImageContent[] = result.content
@@ -258,7 +257,7 @@ export class EventController {
258
257
  .map(content => ({ type: "image", data: content.data, mimeType: content.mimeType }));
259
258
  if (images.length === 0) return false;
260
259
  assistantComponent.setToolResultImages(toolCallId, images);
261
- return true;
260
+ return settings.get("terminal.showImages");
262
261
  }
263
262
 
264
263
  #insertAfterTranscriptComponent(anchor: Component | undefined, component: Component): boolean {
@@ -771,7 +771,7 @@ export class InputController {
771
771
  // While loop mode is on, every user-typed prompt becomes the new loop
772
772
  // prompt that auto-resubmits after each yield.
773
773
  if (this.ctx.loopModeEnabled) {
774
- this.ctx.loopPrompt = text;
774
+ this.ctx.setLoopPrompt(text);
775
775
  }
776
776
 
777
777
  // Queue input during compaction
@@ -442,13 +442,20 @@ export class SelectorController {
442
442
  break;
443
443
 
444
444
  // Settings with UI side effects
445
- case "showImages":
445
+ case "terminal.showImages":
446
+ case "showImages": {
447
+ const visible = value as boolean;
446
448
  for (const child of this.ctx.chatContainer.children) {
447
449
  if (child instanceof ToolExecutionComponent) {
448
- child.setShowImages(value as boolean);
450
+ child.setShowImages(visible);
451
+ } else if (child instanceof AssistantMessageComponent) {
452
+ child.setImagesVisible(visible);
449
453
  }
450
454
  }
455
+ if (!visible) this.ctx.ui.clearInlineImages();
456
+ this.ctx.ui.resetDisplay();
451
457
  break;
458
+ }
452
459
  case "hideThinkingBlock":
453
460
  this.ctx.hideThinkingBlock = value as boolean;
454
461
  for (const child of this.ctx.chatContainer.children) {
@@ -1130,7 +1137,7 @@ export class SelectorController {
1130
1137
  tree,
1131
1138
  realLeafId,
1132
1139
  this.ctx.ui.terminal.rows,
1133
- async entryId => {
1140
+ async (entryId, options) => {
1134
1141
  // Selecting the current leaf is a no-op (already there)
1135
1142
  if (entryId === realLeafId) {
1136
1143
  done();
@@ -1141,13 +1148,15 @@ export class SelectorController {
1141
1148
  // Ask about summarization
1142
1149
  done(); // Close selector first
1143
1150
 
1144
- // Loop until user makes a complete choice or cancels to tree
1145
- let wantsSummary = false;
1151
+ // Loop until user makes a complete choice or cancels to tree.
1152
+ // Shift+Enter in the tree selector pre-answers "Summarize" and
1153
+ // skips the prompt entirely.
1154
+ let wantsSummary = options.summarize;
1146
1155
  let customInstructions: string | undefined;
1147
1156
 
1148
1157
  const branchSummariesEnabled = settings.get("branchSummary.enabled");
1149
1158
 
1150
- while (branchSummariesEnabled) {
1159
+ while (!wantsSummary && branchSummariesEnabled) {
1151
1160
  const summaryChoice = await this.ctx.showHookSelector("Summarize branch?", [
1152
1161
  "No summary",
1153
1162
  "Summarize",
@@ -1476,7 +1485,14 @@ export class SelectorController {
1476
1485
  // focus (#5339).
1477
1486
  onManualCodeInput: useManualInput ? () => dialog.showManualInput(MANUAL_LOGIN_PROMPT) : undefined,
1478
1487
  });
1479
- this.ctx.session.modelRegistry.refreshInBackground();
1488
+ // Scope the post-login refresh to the just-authenticated provider with an
1489
+ // `online` strategy: the default all-provider `online-if-uncached` reuses
1490
+ // a fresh authoritative cache row (e.g. an empty result fetched before
1491
+ // login), so newly persisted credentials would never re-run discovery and
1492
+ // models would stay unavailable in-session (#5780). Unrelated providers
1493
+ // are left untouched. `refreshProvider` swallows discovery failures, so
1494
+ // awaiting cannot reject the login.
1495
+ await this.ctx.session.modelRegistry.refreshProvider(providerId, "online");
1480
1496
  const block = new TranscriptBlock();
1481
1497
  // Name the account (and Anthropic organization) that was stored so a
1482
1498
  // login that lands on an unintended account/subscription is visible
@@ -1516,7 +1532,12 @@ export class SelectorController {
1516
1532
  return;
1517
1533
  }
1518
1534
 
1519
- await this.ctx.session.modelRegistry.refresh();
1535
+ // Provider-scoped online refresh so the removed credential's stale
1536
+ // endpoint/deployment models are invalidated deterministically; the
1537
+ // default all-provider `online-if-uncached` would reuse the fresh
1538
+ // authoritative cache row and keep showing models the credential
1539
+ // unlocked (#5780). Other providers are left untouched.
1540
+ await this.ctx.session.modelRegistry.refreshProvider(providerId, "online");
1520
1541
  const block = new TranscriptBlock();
1521
1542
  block.addChild(
1522
1543
  new Text(
@@ -208,6 +208,8 @@ import type {
208
208
  } from "./types";
209
209
  import { UiHelpers } from "./utils/ui-helpers";
210
210
 
211
+ const STILL_CLOSING_DELAY_MS = 3_000;
212
+
211
213
  const HINT_SHIMMER_PALETTE: ShimmerPalette = {
212
214
  low: "dim",
213
215
  mid: "muted",
@@ -451,6 +453,7 @@ export class InteractiveMode implements InteractiveModeContext {
451
453
  vibeModeEnabled = false;
452
454
  planModePlanFilePath: string | undefined = undefined;
453
455
  loopModeEnabled = false;
456
+ loopModePaused = false;
454
457
  loopPrompt: string | undefined = undefined;
455
458
  loopLimit: LoopLimitRuntime | undefined = undefined;
456
459
  #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
@@ -696,6 +699,7 @@ export class InteractiveMode implements InteractiveModeContext {
696
699
  this.errorBannerContainer = new AnchoredLiveContainer();
697
700
  this.modelCycleContainer = new AnchoredLiveContainer();
698
701
  this.editor = new CustomEditor(getEditorTheme());
702
+ this.ui.enableScopedInputRender(this.editor);
699
703
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
700
704
  this.editor.setImeSafeCursorLayout(settings.get("tui.imeSafeCursor"));
701
705
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
@@ -1349,6 +1353,7 @@ export class InteractiveMode implements InteractiveModeContext {
1349
1353
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
1350
1354
  return;
1351
1355
  }
1356
+ this.#syncLoopModeStatus();
1352
1357
 
1353
1358
  if (action === "compact") {
1354
1359
  await this.handleCompactCommand();
@@ -1358,19 +1363,36 @@ export class InteractiveMode implements InteractiveModeContext {
1358
1363
  this.#submitLoopPromptWhenReady(prompt);
1359
1364
  }
1360
1365
 
1366
+ #syncLoopModeStatus(): void {
1367
+ const state: "waiting" | "running" | "paused" = this.loopModePaused
1368
+ ? "paused"
1369
+ : this.loopPrompt
1370
+ ? "running"
1371
+ : "waiting";
1372
+ this.statusLine.setLoopModeStatus(this.loopModeEnabled ? { state, limit: this.loopLimit } : undefined);
1373
+ this.ui.requestRender();
1374
+ }
1375
+
1361
1376
  disableLoopMode(message = "Loop mode disabled."): void {
1362
1377
  const wasEnabled = this.loopModeEnabled;
1363
1378
  this.loopModeEnabled = false;
1379
+ this.loopModePaused = false;
1364
1380
  this.loopPrompt = undefined;
1365
1381
  this.loopLimit = undefined;
1366
1382
  this.#cancelLoopAutoSubmit();
1367
- this.statusLine.setLoopModeStatus(undefined);
1368
- this.ui.requestRender();
1383
+ this.#syncLoopModeStatus();
1369
1384
  if (wasEnabled) {
1370
1385
  this.showStatus(message);
1371
1386
  }
1372
1387
  }
1373
1388
 
1389
+ setLoopPrompt(prompt: string): void {
1390
+ if (!this.loopModeEnabled) return;
1391
+ this.loopPrompt = prompt;
1392
+ this.loopModePaused = false;
1393
+ this.#syncLoopModeStatus();
1394
+ }
1395
+
1374
1396
  /**
1375
1397
  * Pause the loop without exiting it: drops the captured prompt and any
1376
1398
  * pending auto-resubmit. Loop mode stays enabled — the next prompt the
@@ -1378,7 +1400,9 @@ export class InteractiveMode implements InteractiveModeContext {
1378
1400
  */
1379
1401
  pauseLoop(): void {
1380
1402
  this.loopPrompt = undefined;
1403
+ this.loopModePaused = true;
1381
1404
  this.#cancelLoopAutoSubmit();
1405
+ this.#syncLoopModeStatus();
1382
1406
  }
1383
1407
 
1384
1408
  async handleLoopCommand(args = ""): Promise<string | undefined> {
@@ -1392,10 +1416,10 @@ export class InteractiveMode implements InteractiveModeContext {
1392
1416
  return undefined;
1393
1417
  }
1394
1418
  this.loopModeEnabled = true;
1419
+ this.loopModePaused = false;
1395
1420
  this.loopPrompt = undefined;
1396
1421
  this.loopLimit = createLoopLimitRuntime(parsed.limit);
1397
- this.statusLine.setLoopModeStatus({ enabled: true });
1398
- this.ui.requestRender();
1422
+ this.#syncLoopModeStatus();
1399
1423
  const limitSuffix = parsed.limit ? ` Limited to ${describeLoopLimit(parsed.limit)}.` : "";
1400
1424
  const remainingSuffix = this.loopLimit ? ` ${describeLoopLimitRuntime(this.loopLimit)}.` : "";
1401
1425
  const tail = parsed.prompt ? "Repeating it after each turn." : "Your next prompt will repeat after each turn.";
@@ -3746,10 +3770,17 @@ export class InteractiveMode implements InteractiveModeContext {
3746
3770
  // first runs the work, the other awaits the same settled promise.
3747
3771
  // The teardown is registered lazily in `init()` — a `/exit` reached
3748
3772
  // before `init()` completed falls back to a direct dispose.
3749
- if (this.#signalTeardown) {
3750
- await this.#signalTeardown();
3751
- } else {
3752
- await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
3773
+ const stillClosingTimer = setTimeout(() => {
3774
+ this.showStatus("Still closing… (flushing memory backend / network)");
3775
+ }, STILL_CLOSING_DELAY_MS);
3776
+ try {
3777
+ if (this.#signalTeardown) {
3778
+ await this.#signalTeardown();
3779
+ } else {
3780
+ await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
3781
+ }
3782
+ } finally {
3783
+ clearTimeout(stillClosingTimer);
3753
3784
  }
3754
3785
 
3755
3786
  // Do not force a final render during teardown: disposed session/UI state can
@@ -3794,6 +3825,7 @@ export class InteractiveMode implements InteractiveModeContext {
3794
3825
  const nextEditor = factory
3795
3826
  ? factory(this.ui, getEditorTheme(), this.keybindings)
3796
3827
  : new CustomEditor(getEditorTheme());
3828
+ if (!factory) this.ui.enableScopedInputRender(nextEditor);
3797
3829
 
3798
3830
  nextEditor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
3799
3831
  nextEditor.setImeSafeCursorLayout(this.settings.get("tui.imeSafeCursor"));
@@ -8,6 +8,7 @@
8
8
  import { describe, expect, it, spyOn } from "bun:test";
9
9
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
10
10
  import type { AgentSession } from "../session/agent-session";
11
+ import * as telemetryExport from "../telemetry-export";
11
12
  import { runPrintMode } from "./print-mode";
12
13
 
13
14
  /** Stand-in for `process.exit`: it terminates, so nothing after it should run. */
@@ -35,11 +36,20 @@ describe("print-mode error exit disposes the session before exit", () => {
35
36
  extensionRunner: undefined,
36
37
  subscribe: () => {},
37
38
  state: { messages: [errorMsg] },
39
+ getLastAssistantMessage: () => errorMsg,
40
+ prepareForHeadlessAdvisorDrain: () => {},
41
+ waitForAdvisorCatchup: async () => {
42
+ order.push("catchup");
43
+ return true;
44
+ },
38
45
  dispose: async () => {
39
46
  order.push("dispose");
40
47
  },
41
48
  } as unknown as AgentSession;
42
49
 
50
+ const flushSpy = spyOn(telemetryExport, "flushTelemetryExport").mockImplementation(async () => {
51
+ order.push("flush");
52
+ });
43
53
  const exitSpy = spyOn(process, "exit").mockImplementation(((code: number) => {
44
54
  order.push("exit");
45
55
  throw new ProcessExit(code);
@@ -53,8 +63,9 @@ describe("print-mode error exit disposes the session before exit", () => {
53
63
  } finally {
54
64
  exitSpy.mockRestore();
55
65
  stderrSpy.mockRestore();
66
+ flushSpy.mockRestore();
56
67
  }
57
68
 
58
- expect(order).toEqual(["dispose", "exit"]);
69
+ expect(order).toEqual(["catchup", "flush", "dispose", "exit"]);
59
70
  });
60
71
  });
@@ -6,7 +6,7 @@
6
6
  * - `omp --mode json "prompt"` - JSON event stream
7
7
  */
8
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
- import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
9
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
10
10
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
11
11
  import { type AgentSession, type AgentSessionEvent, SHUTDOWN_CONSOLIDATE_BUDGET_MS } from "../session/agent-session";
12
12
  import { isSilentAbort } from "../session/messages";
@@ -29,6 +29,11 @@ export interface PrintModeOptions {
29
29
  printThoughts?: boolean;
30
30
  }
31
31
 
32
+ /** Matches the longest built-in provider request deadline while bounding tool-loop stalls. */
33
+ export const PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS = 10 * 60_000;
34
+ /** Error exits cannot hold automation for the full normal drain budget. */
35
+ export const PRINT_MODE_ERROR_ADVISOR_DRAIN_TIMEOUT_MS = 30_000;
36
+
32
37
  /** Drop the provider-opaque replay payload (e.g. encrypted reasoning items) before printing. */
33
38
  function stripProviderPayload<T extends AgentMessage>(message: T): T {
34
39
  if (!("providerPayload" in message) || message.providerPayload === undefined) return message;
@@ -130,14 +135,19 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
130
135
  await logger.time("print:prompt:next", () => session.prompt(message));
131
136
  }
132
137
 
138
+ // From this point onward a late blocker must be recorded without starting a
139
+ // primary turn whose response print mode would never emit.
140
+ session.prepareForHeadlessAdvisorDrain();
141
+
133
142
  // In text mode, output final response
134
143
  if (mode === "text") {
135
- const state = session.state;
136
- const lastMessage = state.messages[state.messages.length - 1];
137
-
138
- if (lastMessage?.role === "assistant") {
139
- const assistantMsg = lastMessage as AssistantMessage;
144
+ // Read via the session accessor, not the raw state tail: a classifier
145
+ // refusal is pruned from active context at settle, and an aborted turn
146
+ // can trail synthetic tool results — both would hide the terminal
147
+ // assistant message (and its error) from a last-element read.
148
+ const assistantMsg = session.getLastAssistantMessage();
140
149
 
150
+ if (assistantMsg) {
141
151
  // Check for error/aborted — skip silent-abort (plan-mode compaction transition)
142
152
  if (
143
153
  (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") &&
@@ -151,6 +161,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
151
161
  // `dispose()` (releaseTabsForOwner) actually runs — otherwise an
152
162
  // OMP-owned Chromium survives this exit (issue #5643). `dispose()`
153
163
  // is idempotent, so the unreachable call below is a harmless no-op.
164
+ await session.waitForAdvisorCatchup(PRINT_MODE_ERROR_ADVISOR_DRAIN_TIMEOUT_MS);
154
165
  await flushTelemetryExport();
155
166
  await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
156
167
  const flushed = process.stderr.write(`${errorLine}\n`);
@@ -180,14 +191,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
180
191
  }
181
192
  }
182
193
 
183
- // Ensure stdout is fully flushed before returning
184
- // This prevents race conditions where the process exits before all output is written
194
+ await session.waitForAdvisorCatchup(PRINT_MODE_ADVISOR_DRAIN_TIMEOUT_MS);
195
+
196
+ // Ensure stdout, including late JSON advisor events, is fully flushed before returning.
197
+ // This prevents race conditions where the process exits before all output is written.
185
198
  await new Promise<void>((resolve, reject) => {
186
199
  process.stdout.write("", err => {
187
200
  if (err) reject(err);
188
201
  else resolve();
189
202
  });
190
203
  });
191
-
192
204
  await session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS });
193
205
  }