@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -76,26 +76,43 @@ function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): st
76
76
  return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
77
77
  }
78
78
 
79
+ /** Format a resolved selector, preserving provider identity when requested. */
80
+ function formatResolvedModelBadge(resolved: string, preserveProvider = false): string {
81
+ // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
82
+ // suffix as a thinking level when it parses as one.
83
+ const colon = resolved.lastIndexOf(":");
84
+ const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
85
+ const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
86
+ const label = preserveProvider ? selector : selector.slice(selector.indexOf("/") + 1);
87
+ return formatModelBadge(label, level);
88
+ }
89
+
79
90
  /**
80
91
  * Active model + reasoning level for a hub row: live session state when the
81
92
  * agent is attached, else the executor-reported `resolvedModel` selector
82
- * (`provider/id`, optionally `:<level>`). Undefined when neither is known
93
+ * (`provider/id`, optionally `:<level>`). Active retry fallbacks retain their
94
+ * provider and carry an explicit marker. Undefined when no model is known
83
95
  * (e.g. a parked historical agent restored from disk).
84
96
  */
85
97
  function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
98
+ const progress = observed?.progress;
99
+ // Prefer the live session's own resolved fallback selector; else honor the
100
+ // executor-reported fallback flag. The latter covers observer-only rows (no
101
+ // live session) AND live rows whose fallback armed no session retry state —
102
+ // e.g. the Fireworks Fast → base degrade, which emits `retry_fallback_applied`
103
+ // without populating `#activeRetryFallback`, so `retryFallbackModel` is undefined.
104
+ const fallbackSelector =
105
+ ref.session?.retryFallbackModel ?? (progress?.resolvedModelIsFallback ? progress.resolvedModel : undefined);
106
+ if (fallbackSelector) {
107
+ return `${theme.fg("warning", "fallback →")} ${formatResolvedModelBadge(fallbackSelector, true)}`;
108
+ }
86
109
  const model = ref.session?.model;
87
110
  if (model) {
88
111
  const level = model.thinking ? ref.session?.thinkingLevel : undefined;
89
112
  return formatModelBadge(model.id, level);
90
113
  }
91
- const resolved = observed?.progress?.resolvedModel;
92
- if (!resolved) return undefined;
93
- // Model ids may themselves contain colons (`qwen3:14b`), so only treat the
94
- // suffix as a thinking level when it parses as one.
95
- const colon = resolved.lastIndexOf(":");
96
- const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
97
- const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
98
- return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
114
+ const resolved = progress?.resolvedModel;
115
+ return resolved ? formatResolvedModelBadge(resolved) : undefined;
99
116
  }
100
117
 
101
118
  /** Result of one host-backed transcript read for the Agent Hub viewer. */
@@ -56,7 +56,7 @@ import { SkillMessageComponent } from "./skill-message";
56
56
  import { ToolExecutionComponent } from "./tool-execution";
57
57
  import { TranscriptContainer } from "./transcript-container";
58
58
  import { createUsageRowBlock } from "./usage-row";
59
- import { UserMessageComponent } from "./user-message";
59
+ import { CollapsedSyntheticMessageComponent, UserMessageComponent } from "./user-message";
60
60
 
61
61
  export interface ChatTranscriptBuilderDeps {
62
62
  ui: TUI;
@@ -233,7 +233,18 @@ export class ChatTranscriptBuilder {
233
233
  const textContent = message.role === "user" ? userMessageText(message) : "";
234
234
  if (textContent) {
235
235
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
236
- this.container.addChild(new UserMessageComponent(textContent, isSynthetic));
236
+ // Synthetic (agent-attributed) inputs — chiefly the advisor's `Session
237
+ // update` replay dumps — can be hundreds of KiB of Markdown each.
238
+ // Rendering their full body on cold open blocked the TUI (issue #6308);
239
+ // collapse them behind a compact summary that builds Markdown only on
240
+ // ctrl+o expand. Real user prompts stay fully rendered.
241
+ if (isSynthetic) {
242
+ const collapsed = new CollapsedSyntheticMessageComponent(textContent);
243
+ this.#trackExpandable(collapsed);
244
+ this.container.addChild(collapsed);
245
+ } else {
246
+ this.container.addChild(new UserMessageComponent(textContent, false));
247
+ }
237
248
  }
238
249
  break;
239
250
  }
@@ -1,5 +1,5 @@
1
+ import { diffWords } from "@oh-my-pi/pi-natives";
1
2
  import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils";
2
- import * as Diff from "diff";
3
3
  import { getLanguageFromPath, highlightCode, theme } from "../../modes/theme/theme";
4
4
  import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../../tools/render-utils";
5
5
 
@@ -53,7 +53,7 @@ function parseDiffLine(line: string): { prefix: CodeFrameMarker; lineNum: string
53
53
  * Strips leading whitespace from inverse to avoid highlighting indentation.
54
54
  */
55
55
  function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
56
- const wordDiff = Diff.diffWords(oldContent, newContent);
56
+ const wordDiff = diffWords(oldContent, newContent);
57
57
 
58
58
  let removedLine = "";
59
59
  let addedLine = "";
@@ -60,6 +60,8 @@ export class OAuthSelectorComponent extends Container {
60
60
  /** First provider index of the visible ScrollView window (last #updateList). */
61
61
  #scrollStart = 0;
62
62
  #visibleCount = 0;
63
+ /** Visible list window, shrunk by {@link setMaxHeight} on short screens. */
64
+ #maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
63
65
  #mode: "login" | "logout";
64
66
  #authStorage: AuthStorage;
65
67
  #onSelectCallback: (providerId: string) => void;
@@ -111,6 +113,24 @@ export class OAuthSelectorComponent extends Container {
111
113
  this.#validationGeneration += 1;
112
114
  this.#stopSpinner();
113
115
  }
116
+
117
+ /**
118
+ * Fit the selector into `lines` rendered rows by shrinking the visible list
119
+ * window (the window is centered on the selection, so the selected row is
120
+ * always visible at any height). Prefers keeping the full chrome — borders,
121
+ * spacers, title, search status — but sacrifices the trailing spacer/border
122
+ * (clipped by the host) before dropping below three visible rows.
123
+ */
124
+ setMaxHeight(lines: number): void {
125
+ // Above the rows: LIST_ROW_OFFSET; below: search status + spacer + border.
126
+ const strict = lines - LIST_ROW_OFFSET - 3;
127
+ // Keeps only the rows + search status inside `lines`.
128
+ const relaxed = lines - LIST_ROW_OFFSET - 1;
129
+ const rows = Math.min(OAUTH_SELECTOR_MAX_VISIBLE, Math.max(1, strict, Math.min(relaxed, 3)));
130
+ if (rows === this.#maxVisible) return;
131
+ this.#maxVisible = rows;
132
+ this.#updateList();
133
+ }
114
134
  #hasSelectableAuth(providerId: string): boolean {
115
135
  return this.#mode === "logout" ? this.#authStorage.has(providerId) : this.#authStorage.hasAuth(providerId);
116
136
  }
@@ -227,7 +247,7 @@ export class OAuthSelectorComponent extends Container {
227
247
  }
228
248
 
229
249
  #isSearchEnabled(): boolean {
230
- return this.#allProviders.length > OAUTH_SELECTOR_MAX_VISIBLE;
250
+ return this.#allProviders.length > this.#maxVisible;
231
251
  }
232
252
 
233
253
  #shouldRenderSearchStatus(): boolean {
@@ -286,7 +306,7 @@ export class OAuthSelectorComponent extends Container {
286
306
  this.#listContainer.clear();
287
307
 
288
308
  const total = this.#filteredProviders.length;
289
- const maxVisible = OAUTH_SELECTOR_MAX_VISIBLE;
309
+ const maxVisible = this.#maxVisible;
290
310
  const startIndex =
291
311
  total <= maxVisible
292
312
  ? 0
@@ -381,7 +401,7 @@ export class OAuthSelectorComponent extends Container {
381
401
  // Page up - jump up by one visible page
382
402
  else if (matchesKey(keyData, "pageUp")) {
383
403
  if (this.#filteredProviders.length > 0) {
384
- this.#selectedIndex = Math.max(0, this.#selectedIndex - OAUTH_SELECTOR_MAX_VISIBLE);
404
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible);
385
405
  }
386
406
  this.#statusMessage = undefined;
387
407
  this.#updateList();
@@ -389,10 +409,7 @@ export class OAuthSelectorComponent extends Container {
389
409
  // Page down - jump down by one visible page
390
410
  else if (matchesKey(keyData, "pageDown")) {
391
411
  if (this.#filteredProviders.length > 0) {
392
- this.#selectedIndex = Math.min(
393
- this.#filteredProviders.length - 1,
394
- this.#selectedIndex + OAUTH_SELECTOR_MAX_VISIBLE,
395
- );
412
+ this.#selectedIndex = Math.min(this.#filteredProviders.length - 1, this.#selectedIndex + this.#maxVisible);
396
413
  }
397
414
  this.#statusMessage = undefined;
398
415
  this.#updateList();
@@ -152,6 +152,14 @@ export class PlanReviewOverlay implements Component {
152
152
  * motion mouse reports and cleared when the pointer leaves the option rows. */
153
153
  #hoveredOption: number | undefined;
154
154
 
155
+ // Once a choice fires, the promise-based caller resolves but keeps this
156
+ // overlay mounted while it runs slow async approval work (e.g. context
157
+ // compaction). Lock input and switch to a "submitting" indicator so the
158
+ // overlay stops looking interactive and repeat Enter/Esc are not silently
159
+ // swallowed with zero feedback (#5926).
160
+ #committed = false;
161
+ /** Label of the committed choice, shown while the async approval settles. */
162
+ #committedLabel: string | undefined;
155
163
  #annotating = false;
156
164
  #input: Input;
157
165
 
@@ -283,11 +291,14 @@ export class PlanReviewOverlay implements Component {
283
291
  #confirmSelection(): void {
284
292
  const index = this.#selectedIndex;
285
293
  if (index >= 0 && index < this.#options.length && !this.#disabled.has(index)) {
294
+ this.#committed = true;
295
+ this.#committedLabel = this.#options[index]!;
286
296
  this.callbacks.onPick(this.#options[index]!);
287
297
  }
288
298
  }
289
299
 
290
300
  handleInput(keyData: string): void {
301
+ if (this.#committed) return;
291
302
  if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
292
303
  if (this.#annotating) {
293
304
  if (this.callbacks.onAnnotationExternalEditor && matchesAppExternalEditor(keyData)) {
@@ -300,6 +311,7 @@ export class PlanReviewOverlay implements Component {
300
311
  return;
301
312
  }
302
313
  if (matchesSelectCancel(keyData)) {
314
+ this.#committed = true;
303
315
  this.callbacks.onCancel();
304
316
  return;
305
317
  }
@@ -838,10 +850,14 @@ export class PlanReviewOverlay implements Component {
838
850
  const innerWidth = Math.max(1, width - 4);
839
851
  const bodyContentWidth = sidebarShown ? splitBodyWidth(width, sidebarWidth) : innerWidth;
840
852
 
841
- const sliderLines = this.#renderSliderLines();
842
- const optionLines = this.#renderOptionLines();
853
+ const committed = this.#committed;
854
+ const sliderLines = committed ? [] : this.#renderSliderLines();
855
+ const submittingLabel = this.#committedLabel ? `${this.#committedLabel} — submitting…` : "Submitting…";
856
+ const optionLines = committed ? [theme.bold(theme.fg("accent", submittingLabel))] : this.#renderOptionLines();
843
857
  const promptLines = this.#promptTitle ? [theme.bold(theme.fg("accent", this.#promptTitle))] : [];
844
- const footerLines = this.#renderFooterLines(innerWidth);
858
+ const footerLines = committed
859
+ ? [theme.fg("dim", "Applying your selection — this can take a moment while context is prepared.")]
860
+ : this.#renderFooterLines(innerWidth);
845
861
 
846
862
  // Chrome rows: top border, two dividers, bottom border, plus the
847
863
  // prompt/slider/option/footer rows between them.
@@ -884,7 +900,7 @@ export class PlanReviewOverlay implements Component {
884
900
  for (const line of promptLines) out.push(row(line, width));
885
901
  for (const line of sliderLines) out.push(row(line, width));
886
902
  for (let i = 0; i < optionLines.length; i++) {
887
- this.#optionClickRows.set(out.length, i);
903
+ if (!committed) this.#optionClickRows.set(out.length, i);
888
904
  out.push(row(optionLines[i]!, width));
889
905
  }
890
906
  out.push(divider(width));
@@ -66,6 +66,7 @@ export interface SubmenuSettingDef extends BaseSettingDef {
66
66
 
67
67
  export interface TextInputSettingDef extends BaseSettingDef {
68
68
  type: "text";
69
+ secret: boolean;
69
70
  }
70
71
 
71
72
  export interface ProviderLimitsSettingDef extends BaseSettingDef {
@@ -176,11 +177,13 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
176
177
  if (options) {
177
178
  return { ...base, type: "submenu", options };
178
179
  }
179
- return { ...base, type: "text" };
180
+ return { ...base, type: "text", secret: ui.secret === true };
180
181
  }
181
182
 
182
183
  if (schemaType === "record") {
183
- return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : { ...base, type: "text" };
184
+ return path === "providers.maxInFlightRequests"
185
+ ? { ...base, type: "providerLimits" }
186
+ : { ...base, type: "text", secret: false };
184
187
  }
185
188
 
186
189
  return null;
@@ -65,6 +65,7 @@ class TextInputSubmenu extends Container {
65
65
  label: string,
66
66
  description: string,
67
67
  currentValue: string,
68
+ secret: boolean,
68
69
  private readonly onSubmit: (value: string) => void,
69
70
  private readonly onCancel: () => void,
70
71
  ) {
@@ -78,6 +79,7 @@ class TextInputSubmenu extends Container {
78
79
  this.addChild(new Spacer(1));
79
80
 
80
81
  this.#input = new Input();
82
+ this.#input.mask = secret;
81
83
  if (currentValue) {
82
84
  this.#input.setValue(currentValue);
83
85
  }
@@ -293,6 +295,7 @@ class ProviderLimitsSubmenu extends Container {
293
295
  `Max In-Flight Requests: ${provider}`,
294
296
  "Enter a positive number. Decimals round down. Clear the field to make this provider unlimited.",
295
297
  limits[provider]?.toString() ?? "",
298
+ false,
296
299
  value => {
297
300
  const next = { ...limits };
298
301
  const trimmed = value.trim();
@@ -837,7 +840,7 @@ export class SettingsSelectorComponent implements Component {
837
840
  id: def.path,
838
841
  label: def.label,
839
842
  description: def.description,
840
- currentValue: this.#formatTextInputValue(def.path, currentValue),
843
+ currentValue: this.#formatTextInputValue(def, currentValue),
841
844
  submenu: (cv, done) => this.#createTextInput(def, cv, done),
842
845
  changed,
843
846
  };
@@ -992,12 +995,13 @@ export class SettingsSelectorComponent implements Component {
992
995
  def.label,
993
996
  def.description,
994
997
  this.#formatTextInputEditValue(def.path, settings.get(def.path)),
998
+ def.secret,
995
999
  value => {
996
1000
  // Empty string clears the setting; undefined-typed string settings
997
1001
  // store "" which the browser.ts expandPath ignores (no-op fallback).
998
1002
  this.#setSettingValue(def.path, value);
999
1003
  this.callbacks.onChange(def.path, settings.get(def.path));
1000
- wrappedDone(this.#formatTextInputValue(def.path, settings.get(def.path)));
1004
+ wrappedDone(this.#formatTextInputValue(def, settings.get(def.path)));
1001
1005
  },
1002
1006
  () => wrappedDone(),
1003
1007
  );
@@ -1022,9 +1026,9 @@ export class SettingsSelectorComponent implements Component {
1022
1026
  return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
1023
1027
  }
1024
1028
 
1025
- #formatTextInputValue(path: SettingPath, value: unknown): string {
1026
- if (path === "providers.maxInFlightRequests") return this.#formatProviderLimitsValue(value);
1027
- return this.#formatTextInputEditValue(path, value);
1029
+ #formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string {
1030
+ if (def.secret) return value ? "••••••••" : "";
1031
+ return this.#formatTextInputEditValue(def.path, value);
1028
1032
  }
1029
1033
 
1030
1034
  #formatTextInputEditValue(_path: SettingPath, value: unknown): string {
@@ -1,7 +1,9 @@
1
- import { beforeAll, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeAll, describe, expect, it } from "bun:test";
2
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import { type Component, Text } from "@oh-my-pi/pi-tui";
4
- import { Settings } from "../../config/settings";
4
+ import { Settings, settings } from "../../config/settings";
5
+ import { renderMCPResult } from "../../mcp/render";
6
+ import type { MCPToolDetails } from "../../mcp/tool-bridge";
5
7
  import { getThemeByName, setThemeInstance, theme } from "../theme/theme";
6
8
  import { ToolExecutionComponent, type ToolExecutionUi } from "./tool-execution";
7
9
 
@@ -26,6 +28,10 @@ describe("ToolExecutionComponent custom renderer failures", () => {
26
28
  setThemeInstance(loaded);
27
29
  });
28
30
 
31
+ afterEach(() => {
32
+ settings.set("mcp.renderMarkdownResults", true);
33
+ });
34
+
29
35
  it("falls back to the custom tool label when a renderCall child component throws during render", () => {
30
36
  const tool: AgentTool = {
31
37
  name: "graphify_graph",
@@ -99,3 +105,58 @@ describe("ToolExecutionComponent custom renderer failures", () => {
99
105
  expect(text).toContain(rawResultText);
100
106
  });
101
107
  });
108
+
109
+ describe("MCP result Markdown rendering", () => {
110
+ const details: MCPToolDetails = {
111
+ serverName: "context-mode",
112
+ mcpToolName: "ctx_search",
113
+ };
114
+
115
+ beforeAll(async () => {
116
+ await Settings.init({ inMemory: true });
117
+ const loaded = await getThemeByName("dark");
118
+ if (!loaded) throw new Error("theme unavailable");
119
+ setThemeInstance(loaded);
120
+ });
121
+
122
+ afterEach(() => {
123
+ settings.set("mcp.renderMarkdownResults", true);
124
+ });
125
+
126
+ it("renders inline Markdown by default", () => {
127
+ const component = renderMCPResult(
128
+ { content: [{ type: "text", text: "**bold result** and `code`" }], details },
129
+ { expanded: true, isPartial: false },
130
+ theme,
131
+ );
132
+ const rendered = visibleText(component.render(80));
133
+
134
+ expect(rendered).toContain("bold result and code");
135
+ expect(rendered).not.toContain("**bold result**");
136
+ expect(rendered).not.toContain("`code`");
137
+ });
138
+
139
+ it("keeps Markdown syntax literal when the setting is disabled", () => {
140
+ settings.set("mcp.renderMarkdownResults", false);
141
+ const component = renderMCPResult(
142
+ { content: [{ type: "text", text: "**bold result**" }], details },
143
+ { expanded: true, isPartial: false },
144
+ theme,
145
+ );
146
+
147
+ expect(visibleText(component.render(80))).toContain("**bold result**");
148
+ });
149
+
150
+ it("preserves structured JSON rendering when Markdown is enabled", () => {
151
+ settings.set("mcp.renderMarkdownResults", true);
152
+ const component = renderMCPResult(
153
+ { content: [{ type: "text", text: '{"status":"**ok**"}' }], details },
154
+ { expanded: true, isPartial: false },
155
+ theme,
156
+ );
157
+ const rendered = visibleText(component.render(80));
158
+
159
+ expect(rendered).toContain("status");
160
+ expect(rendered).toContain("**ok**");
161
+ });
162
+ });
@@ -1,4 +1,5 @@
1
- import { Container, Markdown } from "@oh-my-pi/pi-tui";
1
+ import { type Component, Container, Markdown } from "@oh-my-pi/pi-tui";
2
+ import { formatBytes } from "@oh-my-pi/pi-utils";
2
3
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
4
  import { imageReferenceHyperlink, renderPlaceholders } from "../image-references";
4
5
  import { highlightMagicKeywords } from "../magic-keywords";
@@ -66,3 +67,101 @@ export class UserMessageComponent extends Container {
66
67
  return wrapped;
67
68
  }
68
69
  }
70
+
71
+ /**
72
+ * Collapsed placeholder for a synthetic (agent-attributed) user input in the
73
+ * file/remote-backed transcript viewer — chiefly the advisor's `Session update`
74
+ * replay dumps, which can each be hundreds of KiB of Markdown and, on cold open,
75
+ * blocked the TUI for tens of seconds while every historical body was laid out
76
+ * before the viewport clip (issue #6308).
77
+ *
78
+ * Collapsed by default: renders one dim summary row (label · size · line count ·
79
+ * expand hint) and builds NO Markdown. The heavy {@link UserMessageComponent} is
80
+ * constructed lazily only when expanded via `ctrl+o`, so blocks above the
81
+ * viewport never pay layout cost until the reader asks to see them. The raw
82
+ * observability data stays intact in `__advisor.jsonl`.
83
+ */
84
+ export class CollapsedSyntheticMessageComponent implements Component {
85
+ #expanded = false;
86
+ #cache?: { width: number; lines: readonly string[] };
87
+ #body?: UserMessageComponent;
88
+ readonly #summary: string;
89
+
90
+ constructor(
91
+ private readonly text: string,
92
+ private readonly imageLinks?: readonly (string | undefined)[],
93
+ ) {
94
+ this.#summary = summarizeSyntheticInput(text);
95
+ }
96
+
97
+ /** ctrl+o toggle: reveal/hide the full Markdown body. */
98
+ setExpanded(expanded: boolean): void {
99
+ if (this.#expanded === expanded) return;
100
+ this.#expanded = expanded;
101
+ this.#cache = undefined;
102
+ }
103
+
104
+ invalidate(): void {
105
+ this.#cache = undefined;
106
+ this.#body?.invalidate?.();
107
+ }
108
+
109
+ dispose(): void {
110
+ this.#body?.dispose?.();
111
+ }
112
+
113
+ render(width: number): readonly string[] {
114
+ width = Math.max(1, width);
115
+ if (this.#cache?.width === width) return this.#cache.lines;
116
+ const lines = this.#expanded ? this.#renderExpanded(width) : [` ${this.#summaryRow(width)}`];
117
+ this.#cache = { width, lines };
118
+ return lines;
119
+ }
120
+
121
+ #renderExpanded(width: number): readonly string[] {
122
+ if (!this.#body) this.#body = new UserMessageComponent(this.text, true, this.imageLinks);
123
+ return [` ${this.#summaryRow(width)}`, ...this.#body.render(width)];
124
+ }
125
+
126
+ #summaryRow(width: number): string {
127
+ const hint = `${theme.sep.dot.trim()} ctrl+o`;
128
+ return theme.fg("dim", truncateSummary(`${this.#summary} ${hint}`, Math.max(10, width - 1)));
129
+ }
130
+ }
131
+
132
+ /** Truncate a plain summary label to `maxWidth` display columns, appending `…`. */
133
+ function truncateSummary(text: string, maxWidth: number): string {
134
+ if (Bun.stringWidth(text, { countAnsiEscapeCodes: false }) <= maxWidth) return text;
135
+ let out = "";
136
+ let w = 0;
137
+ for (const ch of text) {
138
+ const cw = Bun.stringWidth(ch, { countAnsiEscapeCodes: false });
139
+ if (w + cw > maxWidth - 1) break;
140
+ out += ch;
141
+ w += cw;
142
+ }
143
+ return `${out}…`;
144
+ }
145
+
146
+ /**
147
+ * One-line summary for a collapsed synthetic input: `<label> · <size> · <n>
148
+ * lines`. The label is the first Markdown heading's text (e.g. `Session
149
+ * update`), falling back to `Synthetic input` when the body opens with none.
150
+ */
151
+ function summarizeSyntheticInput(text: string): string {
152
+ const size = formatBytes(Buffer.byteLength(text, "utf-8"));
153
+ const lineCount = text === "" ? 0 : text.split("\n").length;
154
+ const dot = theme.sep.dot.trim();
155
+ return `${syntheticInputLabel(text)} ${dot} ${size} ${dot} ${lineCount} line${lineCount === 1 ? "" : "s"}`;
156
+ }
157
+
158
+ /** First Markdown heading text in `text`, else `Synthetic input`. */
159
+ function syntheticInputLabel(text: string): string {
160
+ for (const raw of text.split("\n")) {
161
+ const line = raw.trim();
162
+ if (!line) continue;
163
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
164
+ return heading ? heading[1]!.trim() || "Synthetic input" : "Synthetic input";
165
+ }
166
+ return "Synthetic input";
167
+ }
@@ -1311,7 +1311,7 @@ export class CommandController {
1311
1311
 
1312
1312
  compactingLoader.stop();
1313
1313
  this.ctx.statusContainer.disposeChildren();
1314
- this.ctx.rebuildChatFromMessages();
1314
+ this.ctx.rebuildChatFromMessages({ reuseSettledComponents: true });
1315
1315
 
1316
1316
  this.ctx.statusLine.invalidate();
1317
1317
  // Same as the auto-compaction rebuild: a collapsed transcript is an
@@ -1636,17 +1636,24 @@ function formatAggregateAmount(limits: UsageLimit[]): string {
1636
1636
  }
1637
1637
 
1638
1638
  function resolveResetRange(limits: UsageLimit[], nowMs: number): string | null {
1639
- const absolute = limits
1640
- .map(limit => limit.window?.resetsAt)
1641
- .filter((value): value is number => value !== undefined && Number.isFinite(value) && value > nowMs);
1642
- if (absolute.length === 0) return null;
1643
- const offsets = absolute.map(value => value - nowMs);
1639
+ const windows = limits
1640
+ .map(limit => limit.window)
1641
+ .filter(
1642
+ (window): window is NonNullable<UsageLimit["window"]> =>
1643
+ window?.resetsAt !== undefined && Number.isFinite(window.resetsAt) && window.resetsAt > nowMs,
1644
+ );
1645
+ if (windows.length === 0) return null;
1646
+ // Use the shared verb when every contributing window agrees (e.g. all "tick");
1647
+ // mixed or absent labels fall back to the generic "resets".
1648
+ const labels = new Set(windows.map(window => window.resetLabel ?? "resets"));
1649
+ const verb = labels.size === 1 ? [...labels][0]! : "resets";
1650
+ const offsets = windows.map(window => window.resetsAt! - nowMs);
1644
1651
  const minReset = Math.min(...offsets);
1645
1652
  const maxReset = Math.max(...offsets);
1646
1653
  if (maxReset - minReset > 60_000) {
1647
- return `resets in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1654
+ return `${verb} in ${formatDuration(minReset)}–${formatDuration(maxReset)}`;
1648
1655
  }
1649
- return `resets in ${formatDuration(minReset)}`;
1656
+ return `${verb} in ${formatDuration(minReset)}`;
1650
1657
  }
1651
1658
  /**
1652
1659
  * Compact one-line quota summary for a single advisor's provider.
@@ -1857,17 +1864,34 @@ export function renderUsageReports(
1857
1864
  for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
1858
1865
  }
1859
1866
 
1867
+ // Order account columns ONCE per provider (worst-first), then apply that
1868
+ // same order to every window group. Sorting each group independently by
1869
+ // its own used fraction (issue #6067) desynchronized the columns: an
1870
+ // account exhausted on its 5h window but light on the weekly window would
1871
+ // land in different column positions on each row, so the positional
1872
+ // `account N` labels denoted different credentials per row and an
1873
+ // exhausted limit appeared under a sibling that still had quota.
1874
+ const accountRank = new Map<UsageReport, number>();
1875
+ providerReports.forEach((report, position) => {
1876
+ const worst = report.limits.reduce((max, limit) => {
1877
+ const fraction = resolveUsedFraction(limit) ?? -1;
1878
+ return fraction > max ? fraction : max;
1879
+ }, -1);
1880
+ // Encode worst-first primary key with the stable position as tiebreak
1881
+ // so accounts tied on pressure keep their discovery order.
1882
+ accountRank.set(report, -worst * 1000 + position);
1883
+ });
1884
+
1860
1885
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1861
1886
  const entries = group.limits.map((limit, index) => ({
1862
1887
  limit,
1863
1888
  report: group.reports[index],
1864
- fraction: resolveUsedFraction(limit),
1865
1889
  index,
1866
1890
  }));
1867
1891
  entries.sort((a, b) => {
1868
- const aFraction = a.fraction ?? -1;
1869
- const bFraction = b.fraction ?? -1;
1870
- if (aFraction !== bFraction) return bFraction - aFraction;
1892
+ const aRank = accountRank.get(a.report) ?? a.index;
1893
+ const bRank = accountRank.get(b.report) ?? b.index;
1894
+ if (aRank !== bRank) return aRank - bRank;
1871
1895
  return a.index - b.index;
1872
1896
  });
1873
1897
  const sortedLimits = entries.map(entry => entry.limit);
@@ -920,6 +920,9 @@ export class EventController {
920
920
  ),
921
921
  );
922
922
  }
923
+ if (displayMessage === event.message) {
924
+ this.ctx.transcriptMessageComponents.set(event.message, this.ctx.streamingComponent);
925
+ }
923
926
  this.ctx.streamingComponent = undefined;
924
927
  this.ctx.streamingMessage = undefined;
925
928
  // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
@@ -1316,7 +1319,7 @@ export class EventController {
1316
1319
  }
1317
1320
  } else if (event.result) {
1318
1321
  this.ctx.lastAssistantUsage = undefined;
1319
- this.ctx.rebuildChatFromMessages();
1322
+ this.ctx.rebuildChatFromMessages({ reuseSettledComponents: true });
1320
1323
  this.ctx.statusLine.invalidate();
1321
1324
  // When history collapses behind the summary divider, the frame
1322
1325
  // shrinks far below the committed row count; without clearing, the
@@ -70,6 +70,12 @@ export class ExtensionUiController {
70
70
  // the rest queue. See `#presentDialog`.
71
71
  #dialogActive = false;
72
72
  #dialogQueue: Array<() => void> = [];
73
+ /**
74
+ * Built once in `initHooksAndCustomTools()`. Reused directly by `/tree`
75
+ * `ask` re-answer (issue #5642) to drive a standalone `AskTool.execute()`
76
+ * call with the same picker/dialog primitives a live tool call would get.
77
+ */
78
+ #toolUIContext: ExtensionUIContext | undefined;
73
79
  constructor(private ctx: InteractiveModeContext) {}
74
80
 
75
81
  /**
@@ -121,6 +127,7 @@ export class ExtensionUiController {
121
127
  setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded),
122
128
  };
123
129
  this.ctx.setToolUIContext(uiContext, true);
130
+ this.#toolUIContext = uiContext;
124
131
 
125
132
  const extensionRunner = this.ctx.session.extensionRunner;
126
133
  if (!extensionRunner) {
@@ -275,6 +282,17 @@ export class ExtensionUiController {
275
282
  });
276
283
  }
277
284
 
285
+ /**
286
+ * The `ExtensionUIContext` built in `initHooksAndCustomTools()` — the same
287
+ * picker/dialog primitives passed as `context.ui` for every live tool
288
+ * call. `/tree` `ask` re-answer (issue #5642) reuses this to drive a
289
+ * standalone `AskTool.execute()` call outside a normal agent turn.
290
+ * `undefined` before hooks have initialized.
291
+ */
292
+ getToolUIContext(): ExtensionUIContext | undefined {
293
+ return this.#toolUIContext;
294
+ }
295
+
278
296
  setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
279
297
  const placement = options?.placement ?? "aboveEditor";
280
298
  this.#removeHookWidget(this.#hookWidgetsAbove, key);