@oh-my-pi/pi-coding-agent 17.0.6 → 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 (83) hide show
  1. package/CHANGELOG.md +65 -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-discovery.ts +3 -2
  32. package/src/config/model-registry.ts +53 -26
  33. package/src/config/model-resolver.ts +56 -31
  34. package/src/config/settings-schema.ts +5 -0
  35. package/src/config/settings.ts +68 -5
  36. package/src/dap/client.ts +86 -7
  37. package/src/edit/diff.ts +4 -4
  38. package/src/eval/py/prelude.py +5 -5
  39. package/src/export/html/template.js +23 -9
  40. package/src/extensibility/extensions/runner.ts +9 -4
  41. package/src/extensibility/extensions/types.ts +3 -0
  42. package/src/extensibility/typebox.ts +22 -9
  43. package/src/hindsight/client.test.ts +42 -0
  44. package/src/hindsight/client.ts +40 -3
  45. package/src/hindsight/config.ts +18 -0
  46. package/src/launch/broker.ts +31 -5
  47. package/src/lsp/index.ts +1 -1
  48. package/src/main.ts +15 -5
  49. package/src/mcp/tool-bridge.ts +9 -0
  50. package/src/mnemopi/state.ts +70 -3
  51. package/src/modes/components/agent-dashboard.ts +6 -2
  52. package/src/modes/components/chat-transcript-builder.ts +13 -2
  53. package/src/modes/components/diff.ts +2 -2
  54. package/src/modes/components/plan-review-overlay.ts +20 -4
  55. package/src/modes/components/user-message.ts +100 -1
  56. package/src/modes/controllers/command-controller.ts +22 -5
  57. package/src/modes/controllers/event-controller.ts +4 -1
  58. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  59. package/src/modes/controllers/input-controller.ts +47 -12
  60. package/src/modes/controllers/selector-controller.ts +82 -6
  61. package/src/modes/interactive-mode.ts +36 -4
  62. package/src/modes/types.ts +16 -3
  63. package/src/modes/utils/ui-helpers.ts +40 -20
  64. package/src/prompts/system/workflow-notice.md +1 -1
  65. package/src/sdk.ts +134 -48
  66. package/src/session/agent-session-error-log.test.ts +59 -0
  67. package/src/session/agent-session.ts +300 -26
  68. package/src/system-prompt.ts +1 -2
  69. package/src/task/executor.ts +41 -27
  70. package/src/task/index.ts +11 -0
  71. package/src/task/prewalk.ts +6 -0
  72. package/src/task/worktree.ts +25 -11
  73. package/src/tools/ask.ts +15 -0
  74. package/src/tools/bash.ts +14 -6
  75. package/src/tools/browser.ts +1 -1
  76. package/src/tools/debug.ts +1 -1
  77. package/src/tools/eval.ts +4 -5
  78. package/src/tools/fetch.ts +1 -1
  79. package/src/tools/hub/launch.ts +7 -0
  80. package/src/tools/tool-timeouts.ts +10 -3
  81. package/src/tools/write.ts +31 -0
  82. package/src/utils/changelog.ts +54 -58
  83. package/src/utils/git.ts +57 -49
package/src/main.ts CHANGED
@@ -882,6 +882,7 @@ export async function buildSessionOptions(
882
882
  cliProvider: parsed.provider,
883
883
  cliModel: parsed.model,
884
884
  modelRegistry,
885
+ availableModels: modelRegistry.getAvailable(),
885
886
  settings: activeSettings,
886
887
  preferences: modelMatchPreferences,
887
888
  });
@@ -954,13 +955,22 @@ export async function buildSessionOptions(
954
955
  if (resolved.warning) {
955
956
  process.stderr.write(`${chalk.yellow(`Warning: ${resolved.warning}`)}\n`);
956
957
  }
958
+ // Prewalk is an optional optimization (off by default): switch to a fast
959
+ // model at the first edit. If its hand-off target can't be resolved or has
960
+ // no configured auth, warn and leave prewalk unarmed rather than aborting
961
+ // startup and locking the user out of the app (issue #6064).
957
962
  if (resolved.error || !resolved.model) {
958
- throw new Error(resolved.error ?? `Model "${parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET}" not found`);
959
- }
960
- if (!modelRegistry.hasConfiguredAuth(resolved.model)) {
961
- throw new Error(`No API key for ${resolved.model.provider}/${resolved.model.id}`);
963
+ const target = parsed.prewalkInto ?? DEFAULT_PREWALK_TARGET;
964
+ process.stderr.write(
965
+ `${chalk.yellow(`Warning: prewalk disabled — ${resolved.error ?? `model "${target}" not found`}`)}\n`,
966
+ );
967
+ } else if (!modelRegistry.hasConfiguredAuth(resolved.model)) {
968
+ process.stderr.write(
969
+ `${chalk.yellow(`Warning: prewalk disabled — no API key for ${resolved.model.provider}/${resolved.model.id}`)}\n`,
970
+ );
971
+ } else {
972
+ options.prewalk = { target: resolved.model, thinkingLevel: resolved.thinkingLevel };
962
973
  }
963
- options.prewalk = { target: resolved.model, thinkingLevel: resolved.thinkingLevel };
964
974
  }
965
975
 
966
976
  if (parsed.planYoloInto !== undefined && !parsed.planYolo) {
@@ -332,6 +332,13 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
332
332
  readonly approval = "write" as const;
333
333
  /** Render completed MCP calls with the result header replacing the pending call header. */
334
334
  readonly mergeCallAndResult = true;
335
+ /**
336
+ * MCP-backed tools opt out of strict structured-output grammar. The server
337
+ * owns validation, and strict mode makes OpenAI-family models over-fill
338
+ * mutually exclusive optional fields (#4336/#4340). Serializers preserve an
339
+ * explicit `false`; an omitted flag would leave nothing to preserve.
340
+ */
341
+ readonly strict = false as const;
335
342
 
336
343
  /** Create MCPTool instances for all tools from an MCP server connection */
337
344
  static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
@@ -418,6 +425,8 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
418
425
  readonly approval = "write" as const;
419
426
  /** Render completed MCP calls with the result header replacing the pending call header. */
420
427
  readonly mergeCallAndResult = true;
428
+ /** See {@link MCPTool.strict}: MCP servers own validation, so stay non-strict. */
429
+ readonly strict = false as const;
421
430
 
422
431
  readonly #fallbackProvider: string | undefined;
423
432
  readonly #fallbackProviderName: string | undefined;
@@ -158,6 +158,39 @@ export interface MnemopiScopedMemoryHit {
158
158
 
159
159
  type MnemopiRetentionMessage = { role: string; content: string };
160
160
 
161
+ interface MnemopiRetentionCursorRow {
162
+ content: string;
163
+ sourceId: string | null;
164
+ retainedThroughUserTurn: number | null;
165
+ }
166
+
167
+ function countRetainedUserTurns(transcript: string): number {
168
+ let turns = 0;
169
+ for (const line of transcript.split(/\r?\n/)) {
170
+ if (line === "[role: user]") turns++;
171
+ }
172
+ return turns;
173
+ }
174
+
175
+ function deriveRetainedTurnCursor(rows: readonly MnemopiRetentionCursorRow[], sessionId: string): number {
176
+ let cursor = 0;
177
+ for (const row of rows) {
178
+ if (Number.isInteger(row.retainedThroughUserTurn) && row.retainedThroughUserTurn !== null) {
179
+ cursor = Math.max(cursor, row.retainedThroughUserTurn);
180
+ continue;
181
+ }
182
+ if (row.sourceId !== sessionId && !row.sourceId?.startsWith(`${sessionId}-`)) continue;
183
+ // Legacy rows carry no explicit cursor. Summing incremental rows looks
184
+ // right, but pre-fix resumed sessions also wrote cumulative rows under the
185
+ // incremental `${sessionId}-<ts>` id shape, so a sum can overshoot the real
186
+ // retained prefix and permanently skip unseen turns. Per-row max can only
187
+ // under-count, which at worst re-stores one suffix before an explicit
188
+ // cursor row takes over.
189
+ cursor = Math.max(cursor, countRetainedUserTurns(row.content));
190
+ }
191
+ return cursor;
192
+ }
193
+
161
194
  function sliceUnretainedMessages(
162
195
  messages: MnemopiRetentionMessage[],
163
196
  lastRetainedTurn: number,
@@ -208,6 +241,7 @@ export class MnemopiSessionState {
208
241
  hasRecalledForFirstTurn: boolean;
209
242
  lastRecallSnippet?: string;
210
243
  unsubscribe?: () => void;
244
+ #retentionCursorLoaded = false;
211
245
 
212
246
  constructor(options: MnemopiSessionStateOptions) {
213
247
  this.sessionId = options.sessionId;
@@ -222,11 +256,15 @@ export class MnemopiSessionState {
222
256
  }
223
257
 
224
258
  setSessionId(sessionId: string): void {
259
+ if (this.sessionId === sessionId) return;
225
260
  this.sessionId = sessionId;
261
+ this.lastRetainedTurn = 0;
262
+ this.#retentionCursorLoaded = false;
226
263
  }
227
264
 
228
265
  resetConversationTracking(): void {
229
266
  this.lastRetainedTurn = 0;
267
+ this.#retentionCursorLoaded = false;
230
268
  this.hasRecalledForFirstTurn = false;
231
269
  this.lastRecallSnippet = undefined;
232
270
  }
@@ -445,11 +483,13 @@ export class MnemopiSessionState {
445
483
  async maybeRetainOnAgentEnd(_messages: AgentMessage[]): Promise<void> {
446
484
  if (!this.config.autoRetain || this.aliasOf) return;
447
485
  const flat = extractMessages(this.session.sessionManager);
486
+ this.#restoreRetainedTurnCursor();
448
487
  const userTurns = flat.filter(message => message.role === "user").length;
449
488
  if (userTurns - this.lastRetainedTurn < this.config.retainEveryNTurns) return;
450
489
  await this.retainMessages(
451
490
  sliceUnretainedMessages(flat, this.lastRetainedTurn),
452
491
  `${this.sessionId}-${Date.now()}`,
492
+ { retainedThroughUserTurn: userTurns },
453
493
  );
454
494
  this.lastRetainedTurn = userTurns;
455
495
  }
@@ -457,14 +497,19 @@ export class MnemopiSessionState {
457
497
  async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise<void> {
458
498
  if (this.aliasOf) return;
459
499
  const flat = extractMessages(this.session.sessionManager);
460
- await this.retainMessages(flat, this.sessionId, options);
461
- this.lastRetainedTurn = flat.filter(message => message.role === "user").length;
500
+ this.#restoreRetainedTurnCursor();
501
+ const userTurns = flat.filter(message => message.role === "user").length;
502
+ await this.retainMessages(sliceUnretainedMessages(flat, this.lastRetainedTurn), this.sessionId, {
503
+ ...options,
504
+ retainedThroughUserTurn: userTurns,
505
+ });
506
+ this.lastRetainedTurn = Math.max(this.lastRetainedTurn, userTurns);
462
507
  }
463
508
 
464
509
  async retainMessages(
465
510
  messages: Array<{ role: string; content: string }>,
466
511
  sourceId: string,
467
- options: { extract?: boolean } = {},
512
+ options: { extract?: boolean; retainedThroughUserTurn?: number } = {},
468
513
  ): Promise<void> {
469
514
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
470
515
  if (!transcript) return;
@@ -478,6 +523,9 @@ export class MnemopiSessionState {
478
523
  session_id: this.sessionId,
479
524
  source_id: sourceId,
480
525
  message_count: messageCount,
526
+ ...(options.retainedThroughUserTurn === undefined
527
+ ? {}
528
+ : { retained_through_user_turn: options.retainedThroughUserTurn }),
481
529
  cwd: this.session.sessionManager.getCwd(),
482
530
  },
483
531
  scope: "bank",
@@ -490,6 +538,25 @@ export class MnemopiSessionState {
490
538
  });
491
539
  }
492
540
 
541
+ #restoreRetainedTurnCursor(): void {
542
+ if (this.#retentionCursorLoaded) return;
543
+ this.#retentionCursorLoaded = true;
544
+ const rows = this.memory.beam.db
545
+ .prepare<MnemopiRetentionCursorRow, [string]>(`
546
+ SELECT
547
+ content,
548
+ json_extract(metadata_json, '$.source_id') AS sourceId,
549
+ CAST(json_extract(metadata_json, '$.retained_through_user_turn') AS INTEGER)
550
+ AS retainedThroughUserTurn
551
+ FROM working_memory
552
+ WHERE source = 'coding-agent-transcript'
553
+ AND json_extract(metadata_json, '$.session_id') = ?
554
+ ORDER BY rowid
555
+ `)
556
+ .all(this.sessionId);
557
+ this.lastRetainedTurn = Math.max(this.lastRetainedTurn, deriveRetainedTurnCursor(rows, this.sessionId));
558
+ }
559
+
493
560
  attachSessionListeners(): void {
494
561
  this.unsubscribe?.();
495
562
  this.unsubscribe = this.session.subscribe((event: AgentSessionEvent) => {
@@ -49,6 +49,7 @@ import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-ar
49
49
  import agentCreationUserPrompt from "../../prompts/system/agent-creation-user.md" with { type: "text" };
50
50
  import { createAgentSession } from "../../sdk";
51
51
  import { discoverAgents } from "../../task/discovery";
52
+ import { resolveAgentPrewalkDefault } from "../../task/prewalk";
52
53
  import type { AgentDefinition, AgentSource } from "../../task/types";
53
54
  import { shortenPath } from "../../tools/render-utils";
54
55
  import { getEditorTheme, theme } from "../theme/theme";
@@ -602,7 +603,7 @@ export class AgentDashboard extends Container {
602
603
  this.#persistPrewalkOverrides();
603
604
  const pattern = resolveAgentPrewalkPattern({
604
605
  settingsOverride: selected.prewalkOverride,
605
- agentPrewalk: selected.prewalk,
606
+ agentPrewalk: resolveAgentPrewalkDefault(selected, this.#settingsManager?.get("task.prewalk") ?? false),
606
607
  });
607
608
  const state = selected.prewalkOverride ?? "agent default";
608
609
  this.#notice = `Prewalk for ${selected.name}: ${state}${pattern ? ` (into ${pattern})` : ""}`;
@@ -1081,7 +1082,10 @@ export class AgentDashboard extends Container {
1081
1082
  const prewalkPattern = selected
1082
1083
  ? resolveAgentPrewalkPattern({
1083
1084
  settingsOverride: selected.prewalkOverride,
1084
- agentPrewalk: selected.prewalk,
1085
+ agentPrewalk: resolveAgentPrewalkDefault(
1086
+ selected,
1087
+ this.#settingsManager?.get("task.prewalk") ?? false,
1088
+ ),
1085
1089
  })
1086
1090
  : undefined;
1087
1091
  const prewalkResolution = prewalkPattern ? this.#resolvePatterns([prewalkPattern]) : undefined;
@@ -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 = "";
@@ -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));
@@ -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
@@ -1857,17 +1857,34 @@ export function renderUsageReports(
1857
1857
  for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
1858
1858
  }
1859
1859
 
1860
+ // Order account columns ONCE per provider (worst-first), then apply that
1861
+ // same order to every window group. Sorting each group independently by
1862
+ // its own used fraction (issue #6067) desynchronized the columns: an
1863
+ // account exhausted on its 5h window but light on the weekly window would
1864
+ // land in different column positions on each row, so the positional
1865
+ // `account N` labels denoted different credentials per row and an
1866
+ // exhausted limit appeared under a sibling that still had quota.
1867
+ const accountRank = new Map<UsageReport, number>();
1868
+ providerReports.forEach((report, position) => {
1869
+ const worst = report.limits.reduce((max, limit) => {
1870
+ const fraction = resolveUsedFraction(limit) ?? -1;
1871
+ return fraction > max ? fraction : max;
1872
+ }, -1);
1873
+ // Encode worst-first primary key with the stable position as tiebreak
1874
+ // so accounts tied on pressure keep their discovery order.
1875
+ accountRank.set(report, -worst * 1000 + position);
1876
+ });
1877
+
1860
1878
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1861
1879
  const entries = group.limits.map((limit, index) => ({
1862
1880
  limit,
1863
1881
  report: group.reports[index],
1864
- fraction: resolveUsedFraction(limit),
1865
1882
  index,
1866
1883
  }));
1867
1884
  entries.sort((a, b) => {
1868
- const aFraction = a.fraction ?? -1;
1869
- const bFraction = b.fraction ?? -1;
1870
- if (aFraction !== bFraction) return bFraction - aFraction;
1885
+ const aRank = accountRank.get(a.report) ?? a.index;
1886
+ const bRank = accountRank.get(b.report) ?? b.index;
1887
+ if (aRank !== bRank) return aRank - bRank;
1871
1888
  return a.index - b.index;
1872
1889
  });
1873
1890
  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);