@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6

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 (119) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/dist/types/config/settings-schema.d.ts +12 -22
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  11. package/dist/types/internal-urls/types.d.ts +14 -0
  12. package/dist/types/lsp/client.d.ts +3 -0
  13. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  14. package/dist/types/lsp/index.d.ts +2 -0
  15. package/dist/types/lsp/utils.d.ts +4 -0
  16. package/dist/types/mcp/manager.d.ts +14 -5
  17. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  18. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  19. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  22. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +2 -2
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/theme/theme.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -2
  27. package/dist/types/session/agent-session.d.ts +10 -7
  28. package/dist/types/session/shake-types.d.ts +3 -3
  29. package/dist/types/task/repair-args.d.ts +52 -0
  30. package/dist/types/tiny/models.d.ts +0 -14
  31. package/dist/types/tiny/title-client.d.ts +28 -2
  32. package/dist/types/tiny/title-protocol.d.ts +8 -9
  33. package/dist/types/tools/ask.d.ts +8 -6
  34. package/dist/types/tools/eval-backends.d.ts +12 -0
  35. package/dist/types/tools/eval-render.d.ts +52 -0
  36. package/dist/types/tools/eval.d.ts +2 -35
  37. package/dist/types/tools/find.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +4 -11
  39. package/dist/types/tools/path-utils.d.ts +7 -0
  40. package/dist/types/tui/output-block.d.ts +11 -10
  41. package/examples/extensions/README.md +1 -0
  42. package/examples/extensions/thinking-note.ts +13 -0
  43. package/package.json +9 -9
  44. package/scripts/build-binary.ts +0 -1
  45. package/src/cli.ts +59 -0
  46. package/src/config/model-registry.ts +33 -4
  47. package/src/config/settings-schema.ts +13 -24
  48. package/src/config/settings.ts +10 -0
  49. package/src/discovery/claude.ts +41 -22
  50. package/src/edit/index.ts +23 -3
  51. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  52. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  53. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  54. package/src/eval/agent-bridge.ts +44 -38
  55. package/src/eval/heartbeat.ts +74 -0
  56. package/src/eval/js/executor.ts +13 -9
  57. package/src/eval/llm-bridge.ts +20 -14
  58. package/src/eval/py/executor.ts +14 -18
  59. package/src/exec/bash-executor.ts +31 -5
  60. package/src/extensibility/custom-tools/types.ts +2 -2
  61. package/src/extensibility/extensions/loader.ts +16 -18
  62. package/src/extensibility/extensions/runner.ts +22 -17
  63. package/src/extensibility/extensions/types.ts +39 -5
  64. package/src/extensibility/shared-events.ts +2 -2
  65. package/src/internal-urls/docs-index.generated.ts +5 -5
  66. package/src/internal-urls/local-protocol.ts +23 -11
  67. package/src/internal-urls/types.ts +15 -0
  68. package/src/lsp/client.ts +28 -5
  69. package/src/lsp/diagnostics-ledger.ts +51 -0
  70. package/src/lsp/index.ts +9 -22
  71. package/src/lsp/utils.ts +21 -0
  72. package/src/mcp/manager.ts +87 -4
  73. package/src/modes/acp/acp-agent.ts +8 -4
  74. package/src/modes/components/assistant-message.ts +28 -1
  75. package/src/modes/components/custom-editor.ts +9 -7
  76. package/src/modes/components/hook-selector.ts +159 -32
  77. package/src/modes/components/tool-execution.ts +20 -4
  78. package/src/modes/controllers/command-controller.ts +7 -39
  79. package/src/modes/controllers/event-controller.ts +38 -28
  80. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  81. package/src/modes/controllers/input-controller.ts +0 -15
  82. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  83. package/src/modes/interactive-mode.ts +2 -1
  84. package/src/modes/rpc/rpc-mode.ts +17 -6
  85. package/src/modes/theme/theme-schema.json +30 -0
  86. package/src/modes/theme/theme.ts +39 -2
  87. package/src/modes/types.ts +2 -1
  88. package/src/modes/utils/ui-helpers.ts +5 -2
  89. package/src/prompts/system/project-prompt.md +3 -2
  90. package/src/prompts/system/subagent-system-prompt.md +12 -8
  91. package/src/prompts/system/system-prompt.md +8 -6
  92. package/src/prompts/tools/ask.md +2 -1
  93. package/src/prompts/tools/eval.md +1 -1
  94. package/src/session/agent-session.ts +75 -103
  95. package/src/session/shake-types.ts +4 -5
  96. package/src/slash-commands/builtin-registry.ts +2 -4
  97. package/src/task/executor.ts +14 -4
  98. package/src/task/index.ts +3 -2
  99. package/src/task/repair-args.ts +117 -0
  100. package/src/tiny/models.ts +0 -28
  101. package/src/tiny/title-client.ts +133 -43
  102. package/src/tiny/title-protocol.ts +11 -16
  103. package/src/tiny/worker.ts +6 -61
  104. package/src/tools/ask.ts +74 -32
  105. package/src/tools/ast-edit.ts +3 -0
  106. package/src/tools/ast-grep.ts +3 -0
  107. package/src/tools/eval-backends.ts +38 -0
  108. package/src/tools/eval-render.ts +750 -0
  109. package/src/tools/eval.ts +27 -754
  110. package/src/tools/find.ts +20 -6
  111. package/src/tools/gh.ts +1 -0
  112. package/src/tools/index.ts +7 -37
  113. package/src/tools/path-utils.ts +13 -2
  114. package/src/tools/read.ts +1 -0
  115. package/src/tools/renderers.ts +1 -1
  116. package/src/tools/search.ts +12 -1
  117. package/src/tools/write.ts +9 -1
  118. package/src/tui/output-block.ts +42 -79
  119. package/src/utils/git.ts +9 -3
@@ -7,6 +7,7 @@ import {
7
7
  extractPrintableText,
8
8
  fuzzyFilter,
9
9
  Markdown,
10
+ type MarkdownTheme,
10
11
  matchesKey,
11
12
  padding,
12
13
  renderInlineMarkdown,
@@ -14,8 +15,8 @@ import {
14
15
  Spacer,
15
16
  Text,
16
17
  type TUI,
17
- truncateToWidth,
18
18
  visibleWidth,
19
+ wrapTextWithAnsi,
19
20
  } from "@oh-my-pi/pi-tui";
20
21
  import { getMarkdownTheme, type ThemeColor, theme } from "../../modes/theme/theme";
21
22
  import {
@@ -69,6 +70,34 @@ export interface HookSelectorOptions {
69
70
  slider?: HookSelectorSlider;
70
71
  }
71
72
 
73
+ export interface HookSelectorOption {
74
+ label: string;
75
+ description?: string;
76
+ }
77
+
78
+ export type HookSelectorOptionInput = string | HookSelectorOption;
79
+
80
+ function normalizeHookSelectorOption(option: HookSelectorOptionInput): HookSelectorOption {
81
+ if (typeof option === "string") return { label: option };
82
+ if (option.description?.trim()) {
83
+ return { label: option.label, description: option.description.trim() };
84
+ }
85
+ return { label: option.label };
86
+ }
87
+
88
+ function splitLeadingSpacesForWrap(line: string, width: number): { indent: string; body: string } {
89
+ let indentLength = 0;
90
+ while (indentLength < line.length && line.charCodeAt(indentLength) === 32) {
91
+ indentLength += 1;
92
+ }
93
+ const maxIndentLength = Math.max(0, width - 1);
94
+ const clampedIndentLength = Math.min(indentLength, maxIndentLength);
95
+ return {
96
+ indent: line.slice(0, clampedIndentLength),
97
+ body: line.slice(indentLength),
98
+ };
99
+ }
100
+
72
101
  class OutlinedList extends Container {
73
102
  #lines: string[] = [];
74
103
 
@@ -81,19 +110,26 @@ class OutlinedList extends Container {
81
110
  const borderColor = (text: string) => theme.fg("border", text);
82
111
  const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
83
112
  const innerWidth = Math.max(1, width - 2);
84
- const content = this.#lines.map(line => {
113
+ const content: string[] = [];
114
+ for (const line of this.#lines) {
85
115
  const normalized = replaceTabs(line);
86
- const fitted = truncateToWidth(normalized, innerWidth);
87
- const pad = Math.max(0, innerWidth - visibleWidth(fitted));
88
- return `${borderColor(theme.boxSharp.vertical)}${fitted}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`;
89
- });
116
+ const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
117
+ const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
118
+ for (const wrappedBody of wrapped.length > 0 ? wrapped : [""]) {
119
+ const wrappedLine = `${indent}${wrappedBody}`;
120
+ const pad = Math.max(0, innerWidth - visibleWidth(wrappedLine));
121
+ content.push(
122
+ `${borderColor(theme.boxSharp.vertical)}${wrappedLine}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`,
123
+ );
124
+ }
125
+ }
90
126
  return [horizontal, ...content, horizontal];
91
127
  }
92
128
  }
93
129
 
94
130
  export class HookSelectorComponent extends Container {
95
- #options: string[];
96
- #filteredOptions: string[];
131
+ #options: HookSelectorOption[];
132
+ #filteredOptions: HookSelectorOption[];
97
133
  #searchQuery = "";
98
134
  #selectedIndex: number;
99
135
  #maxVisible: number;
@@ -110,17 +146,18 @@ export class HookSelectorComponent extends Container {
110
146
  #slider: HookSelectorSlider | undefined;
111
147
  #sliderIndex: number = 0;
112
148
  #sliderComponent: Text | undefined;
149
+ #lastRenderWidth: number | undefined;
113
150
  constructor(
114
151
  title: string,
115
- options: string[],
152
+ options: HookSelectorOptionInput[],
116
153
  onSelect: (option: string) => void,
117
154
  onCancel: () => void,
118
155
  opts?: HookSelectorOptions,
119
156
  ) {
120
157
  super();
121
158
 
122
- this.#options = options;
123
- this.#filteredOptions = options;
159
+ this.#options = options.map(normalizeHookSelectorOption);
160
+ this.#filteredOptions = this.#options;
124
161
  this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, this.#filteredOptions.length - 1);
125
162
  this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
126
163
  this.#onSelectCallback = onSelect;
@@ -156,7 +193,7 @@ export class HookSelectorComponent extends Container {
156
193
  opts?.onTimeout?.();
157
194
  const selected = this.#filteredOptions[this.#selectedIndex];
158
195
  if (selected) {
159
- this.#onSelectCallback(selected);
196
+ this.#onSelectCallback(selected.label);
160
197
  } else {
161
198
  this.#onCancelCallback();
162
199
  }
@@ -180,32 +217,111 @@ export class HookSelectorComponent extends Container {
180
217
  this.#updateList();
181
218
  }
182
219
 
183
- #updateList(): void {
220
+ #renderOptionLines(option: HookSelectorOption, isSelected: boolean, mdTheme: MarkdownTheme): string[] {
221
+ const label = isSelected
222
+ ? renderInlineMarkdown(option.label, mdTheme, t => theme.fg("accent", t))
223
+ : renderInlineMarkdown(option.label, mdTheme, t => theme.fg("text", t));
224
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
225
+ const lines = [prefix + label];
226
+ if (option.description) {
227
+ const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg("muted", t));
228
+ lines.push(` ${description}`);
229
+ }
230
+ return lines;
231
+ }
232
+
233
+ #renderedLineRowCount(line: string, renderWidth: number): number {
234
+ const normalized = replaceTabs(line);
235
+ if (this.#outlinedList) {
236
+ const innerWidth = Math.max(1, renderWidth - 2);
237
+ const { indent, body } = splitLeadingSpacesForWrap(normalized, innerWidth);
238
+ const wrapped = wrapTextWithAnsi(body, Math.max(1, innerWidth - visibleWidth(indent)));
239
+ return Math.max(1, wrapped.length);
240
+ }
241
+ const wrapped = wrapTextWithAnsi(normalized, Math.max(1, renderWidth - 2));
242
+ return Math.max(1, wrapped.length);
243
+ }
244
+
245
+ #optionRowCount(
246
+ option: HookSelectorOption,
247
+ renderWidth: number | undefined,
248
+ isSelected: boolean,
249
+ mdTheme: MarkdownTheme,
250
+ ): number {
251
+ if (renderWidth === undefined) return option.description ? 2 : 1;
252
+ let rows = 0;
253
+ for (const line of this.#renderOptionLines(option, isSelected, mdTheme)) {
254
+ rows += this.#renderedLineRowCount(line, renderWidth);
255
+ }
256
+ return rows;
257
+ }
258
+
259
+ #totalOptionRows(options: HookSelectorOption[], renderWidth?: number, mdTheme?: MarkdownTheme): number {
260
+ const themeForRows = mdTheme ?? getMarkdownTheme();
261
+ let rows = 0;
262
+ for (const option of options) {
263
+ rows += this.#optionRowCount(option, renderWidth, false, themeForRows);
264
+ }
265
+ return rows;
266
+ }
267
+
268
+ #getVisibleOptionRange(
269
+ total: number,
270
+ renderWidth?: number,
271
+ mdTheme: MarkdownTheme = getMarkdownTheme(),
272
+ ): { startIndex: number; endIndex: number } {
273
+ if (total === 0) return { startIndex: 0, endIndex: 0 };
274
+
275
+ const rowBudget = Math.max(1, this.#maxVisible);
276
+ const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
277
+ let startIndex = selectedIndex;
278
+ let endIndex = selectedIndex + 1;
279
+ let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!, renderWidth, true, mdTheme);
280
+ let beforeRows = 0;
281
+ const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
282
+
283
+ while (startIndex > 0) {
284
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
285
+ if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
286
+ startIndex--;
287
+ beforeRows += cost;
288
+ rows += cost;
289
+ }
290
+
291
+ while (endIndex < total) {
292
+ const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!, renderWidth, false, mdTheme);
293
+ if (rows + cost > rowBudget) break;
294
+ endIndex++;
295
+ rows += cost;
296
+ }
297
+
298
+ while (startIndex > 0) {
299
+ const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!, renderWidth, false, mdTheme);
300
+ if (rows + cost > rowBudget) break;
301
+ startIndex--;
302
+ rows += cost;
303
+ }
304
+
305
+ return { startIndex, endIndex };
306
+ }
307
+
308
+ #updateList(renderWidth = this.#lastRenderWidth): void {
184
309
  const lines: string[] = [];
185
310
  const total = this.#filteredOptions.length;
186
- const startIndex = Math.max(
187
- 0,
188
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), total - this.#maxVisible),
189
- );
190
- const endIndex = Math.min(startIndex + this.#maxVisible, total);
191
-
192
311
  const mdTheme = getMarkdownTheme();
312
+ const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme);
313
+
193
314
  for (let i = startIndex; i < endIndex; i++) {
194
315
  const option = this.#filteredOptions[i];
195
316
  if (option === undefined) continue;
196
- const isSelected = i === this.#selectedIndex;
197
- const label = isSelected
198
- ? renderInlineMarkdown(option, mdTheme, t => theme.fg("accent", t))
199
- : renderInlineMarkdown(option, mdTheme, t => theme.fg("text", t));
200
- const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
201
- lines.push(prefix + label);
317
+ lines.push(...this.#renderOptionLines(option, i === this.#selectedIndex, mdTheme));
202
318
  }
203
319
 
204
320
  if (total === 0) {
205
321
  lines.push(theme.fg("dim", " No matching options"));
206
322
  }
207
323
 
208
- if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus()) {
324
+ if (startIndex > 0 || endIndex < total || this.#shouldRenderSearchStatus(renderWidth, mdTheme)) {
209
325
  lines.push(this.#renderStatusLine(total));
210
326
  }
211
327
  if (this.#outlinedList) {
@@ -253,12 +369,12 @@ export class HookSelectorComponent extends Container {
253
369
  slider.onChange?.(next);
254
370
  }
255
371
 
256
- #isSearchEnabled(): boolean {
257
- return this.#options.length > this.#maxVisible;
372
+ #isSearchEnabled(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
373
+ return this.#totalOptionRows(this.#options, renderWidth, mdTheme) > this.#maxVisible;
258
374
  }
259
375
 
260
- #shouldRenderSearchStatus(): boolean {
261
- return this.#isSearchEnabled() || this.#searchQuery.length > 0;
376
+ #shouldRenderSearchStatus(renderWidth = this.#lastRenderWidth, mdTheme?: MarkdownTheme): boolean {
377
+ return this.#isSearchEnabled(renderWidth, mdTheme) || this.#searchQuery.length > 0;
262
378
  }
263
379
 
264
380
  #renderStatusLine(total: number): string {
@@ -273,7 +389,9 @@ export class HookSelectorComponent extends Container {
273
389
 
274
390
  #setSearchQuery(query: string): void {
275
391
  this.#searchQuery = query;
276
- this.#filteredOptions = query.trim() ? fuzzyFilter(this.#options, query, option => option) : this.#options;
392
+ this.#filteredOptions = query.trim()
393
+ ? fuzzyFilter(this.#options, query, option => `${option.label} ${option.description ?? ""}`)
394
+ : this.#options;
277
395
  this.#selectedIndex = 0;
278
396
  this.#updateList();
279
397
  }
@@ -322,7 +440,7 @@ export class HookSelectorComponent extends Container {
322
440
  }
323
441
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
324
442
  const selected = this.#filteredOptions[this.#selectedIndex];
325
- if (selected) this.#onSelectCallback(selected);
443
+ if (selected) this.#onSelectCallback(selected.label);
326
444
  } else if (matchesKey(keyData, "left") || (this.#slider && !this.#isSearchEnabled() && keyData === "h")) {
327
445
  if (this.#slider) this.#moveSlider(-1);
328
446
  else this.#onLeftCallback?.();
@@ -334,6 +452,15 @@ export class HookSelectorComponent extends Container {
334
452
  }
335
453
  }
336
454
 
455
+ override render(width: number): string[] {
456
+ const renderWidth = Math.max(1, width);
457
+ if (this.#lastRenderWidth !== renderWidth) {
458
+ this.#lastRenderWidth = renderWidth;
459
+ this.#updateList(renderWidth);
460
+ }
461
+ return super.render(renderWidth);
462
+ }
463
+
337
464
  dispose(): void {
338
465
  this.#countdown?.dispose();
339
466
  }
@@ -141,6 +141,14 @@ export interface ToolExecutionHandle {
141
141
  setExpanded(expanded: boolean): void;
142
142
  }
143
143
 
144
+ /** Drive pending-tool redraws at ~60fps so the animated border sweep is smooth.
145
+ * The TUI already throttles at its 16ms `MIN_RENDER_INTERVAL_MS`, so this is the
146
+ * natural upper bound and static frames diff to a no-op redraw at ~zero cost. */
147
+ const SPINNER_RENDER_INTERVAL_MS = 16;
148
+ /** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
149
+ * 60fps render cadence (mirrors `Loader`). */
150
+ const SPINNER_GLYPH_ADVANCE_MS = 80;
151
+
144
152
  /**
145
153
  * Component that renders a tool call with its result (updateable)
146
154
  */
@@ -177,6 +185,7 @@ export class ToolExecutionComponent extends Container {
177
185
  // Spinner animation for partial task results
178
186
  #spinnerFrame?: number;
179
187
  #spinnerInterval?: NodeJS.Timeout;
188
+ #lastSpinnerAdvanceAt = 0;
180
189
  // Todo write completion strikethrough reveal animation
181
190
  #todoStrikeInterval?: NodeJS.Timeout;
182
191
  // Track if args are still being streamed (for edit/write spinner)
@@ -404,13 +413,20 @@ export class ToolExecutionComponent extends Container {
404
413
  this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
405
414
  const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
406
415
  if (needsSpinner && !this.#spinnerInterval) {
416
+ this.#lastSpinnerAdvanceAt = performance.now();
407
417
  this.#spinnerInterval = setInterval(() => {
418
+ const now = performance.now();
408
419
  const frameCount = theme.spinnerFrames.length;
409
- if (frameCount === 0) return;
410
- this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
411
- this.#renderState.spinnerFrame = this.#spinnerFrame;
420
+ // Redraw at ~60fps for a smooth border sweep, but only step the spinner
421
+ // glyph at its classic ~12.5fps cadence. The TUI throttles renders at
422
+ // 16ms and the differ drops no-op redraws, so the extra ticks are free.
423
+ if (frameCount > 0 && now - this.#lastSpinnerAdvanceAt >= SPINNER_GLYPH_ADVANCE_MS) {
424
+ this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
425
+ this.#renderState.spinnerFrame = this.#spinnerFrame;
426
+ this.#lastSpinnerAdvanceAt = now;
427
+ }
412
428
  this.#ui.requestRender();
413
- }, 80);
429
+ }, SPINNER_RENDER_INTERVAL_MS);
414
430
  } else if (!needsSpinner && this.#spinnerInterval) {
415
431
  clearInterval(this.#spinnerInterval);
416
432
  this.#spinnerInterval = undefined;
@@ -1124,48 +1124,16 @@ export class CommandController {
1124
1124
  }
1125
1125
 
1126
1126
  /**
1127
- * TUI handler for `/shake`. `elide`/`images` are instant structural drops;
1128
- * `summary` runs the local on-device compressor behind a cancelable loader
1129
- * (Esc aborts via `abortCompaction`). Rebuilds the chat and reports counts.
1127
+ * TUI handler for `/shake`. `elide` drops heavy structural content and
1128
+ * `images` strips image blocks. Rebuilds the chat and reports counts.
1130
1129
  */
1131
1130
  async handleShakeCommand(mode: ShakeMode): Promise<void> {
1132
1131
  let result: ShakeResult;
1133
- if (mode === "summary") {
1134
- if (this.ctx.loadingAnimation) {
1135
- this.ctx.loadingAnimation.stop();
1136
- this.ctx.loadingAnimation = undefined;
1137
- }
1138
- this.ctx.statusContainer.clear();
1139
- const originalOnEscape = this.ctx.editor.onEscape;
1140
- this.ctx.editor.onEscape = () => {
1141
- this.ctx.session.abortCompaction();
1142
- };
1143
- const loader = new Loader(
1144
- this.ctx.ui,
1145
- spinner => theme.fg("accent", spinner),
1146
- text => theme.fg("muted", text),
1147
- "Shaking context (summary)… (esc to cancel)",
1148
- getSymbolTheme().spinnerFrames,
1149
- );
1150
- this.ctx.statusContainer.addChild(loader);
1151
- this.ctx.ui.requestRender();
1152
- try {
1153
- result = await this.ctx.session.shake("summary");
1154
- } catch (error) {
1155
- this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1156
- return;
1157
- } finally {
1158
- loader.stop();
1159
- this.ctx.statusContainer.clear();
1160
- this.ctx.editor.onEscape = originalOnEscape;
1161
- }
1162
- } else {
1163
- try {
1164
- result = await this.ctx.session.shake(mode);
1165
- } catch (error) {
1166
- this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1167
- return;
1168
- }
1132
+ try {
1133
+ result = await this.ctx.session.shake(mode);
1134
+ } catch (error) {
1135
+ this.ctx.showError(`Shake failed: ${error instanceof Error ? error.message : String(error)}`);
1136
+ return;
1169
1137
  }
1170
1138
 
1171
1139
  const dropped = result.toolResultsDropped + result.blocksDropped + (result.imagesDropped ?? 0);
@@ -25,12 +25,14 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
25
25
 
26
26
  const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
27
27
 
28
- // Events that change which foreground tools are executing, or that reset a turn.
29
- // The eager native-scrollback rebuild mode is recomputed only on these other
30
- // events (assistant text streaming, IRC, notices) leave it untouched so plain
31
- // streaming keeps the no-yank deferral.
32
- const TOOL_RENDER_MODE_EVENTS: Record<string, true> = {
28
+ // Events that change foreground streaming state, or that reset a turn. The TUI
29
+ // eager native-scrollback rebuild mode is recomputed only on these so unrelated
30
+ // IRC/notices/status refreshes do not toggle scrollback replay policy.
31
+ const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
33
32
  agent_start: true,
33
+ agent_end: true,
34
+ message_start: true,
35
+ message_end: true,
34
36
  tool_execution_start: true,
35
37
  tool_execution_update: true,
36
38
  tool_execution_end: true,
@@ -46,6 +48,7 @@ export class EventController {
46
48
  #renderedCustomMessages = new Set<string>();
47
49
  #lastIntent: string | undefined = undefined;
48
50
  #backgroundToolCallIds = new Set<string>();
51
+ #assistantMessageStreaming = false;
49
52
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
50
53
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
51
54
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -169,24 +172,27 @@ export class EventController {
169
172
 
170
173
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
171
174
  await run(event);
172
- // While a foreground tool is executing, its streaming result re-renders and can
173
- // re-lay-out rows that already scrolled into native scrollback. Let the TUI
174
- // rebuild history on those offscreen edits (a snap to the tail is acceptable
175
- // mid-tool) instead of deferring, which would leave stale/duplicated rows.
176
- // Background-running tools are excluded so their late async updates — and the
177
- // assistant text that streams alongside them keep the no-yank deferral;
178
- // agent_start resets the mode at every turn boundary.
179
- if (TOOL_RENDER_MODE_EVENTS[event.type]) {
175
+ // While assistant text or a foreground tool is streaming, rows above the
176
+ // viewport can re-layout after they have already entered native scrollback
177
+ // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
+ // those offscreen edits instead of deferring, which otherwise leaves stale
179
+ // tail rows duplicated above the live viewport.
180
+ // Background-running tools are excluded so late async updates outside the
181
+ // active foreground stream keep the no-yank deferral; agent_start resets
182
+ // the mode at every turn boundary.
183
+ if (STREAM_RENDER_MODE_EVENTS[event.type]) {
180
184
  this.#refreshToolRenderMode();
181
185
  }
182
186
  }
183
187
 
184
188
  #refreshToolRenderMode(): void {
185
- let foregroundToolActive = false;
186
- for (const toolCallId of this.ctx.pendingTools.keys()) {
187
- if (!this.#backgroundToolCallIds.has(toolCallId)) {
188
- foregroundToolActive = true;
189
- break;
189
+ let foregroundToolActive = this.#assistantMessageStreaming;
190
+ if (!foregroundToolActive) {
191
+ for (const toolCallId of this.ctx.pendingTools.keys()) {
192
+ if (!this.#backgroundToolCallIds.has(toolCallId)) {
193
+ foregroundToolActive = true;
194
+ break;
195
+ }
190
196
  }
191
197
  }
192
198
  this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
@@ -196,6 +202,7 @@ export class EventController {
196
202
  this.#lastIntent = undefined;
197
203
  this.#readToolCallArgs.clear();
198
204
  this.#readToolCallAssistantComponents.clear();
205
+ this.#assistantMessageStreaming = false;
199
206
  this.#lastAssistantComponent = undefined;
200
207
  if (this.ctx.retryEscapeHandler) {
201
208
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -268,9 +275,13 @@ export class EventController {
268
275
  this.ctx.ui.requestRender();
269
276
  } else if (event.message.role === "assistant") {
270
277
  this.#lastThinkingCount = 0;
278
+ this.#assistantMessageStreaming = true;
271
279
  this.#resetReadGroup();
272
- this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
273
- this.ctx.ui.requestRender(),
280
+ this.ctx.streamingComponent = new AssistantMessageComponent(
281
+ undefined,
282
+ this.ctx.hideThinkingBlock,
283
+ () => this.ctx.ui.requestRender(),
284
+ this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
274
285
  );
275
286
  this.ctx.streamingMessage = event.message;
276
287
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -414,6 +425,9 @@ export class EventController {
414
425
 
415
426
  async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
416
427
  if (event.message.role === "user") return;
428
+ if (event.message.role === "assistant") {
429
+ this.#assistantMessageStreaming = false;
430
+ }
417
431
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
418
432
  this.ctx.streamingMessage = event.message;
419
433
  let errorMessage: string | undefined;
@@ -596,8 +610,8 @@ export class EventController {
596
610
  }
597
611
  }
598
612
  }
599
-
600
613
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
614
+ this.#assistantMessageStreaming = false;
601
615
  if (this.ctx.loadingAnimation) {
602
616
  this.ctx.loadingAnimation.stop();
603
617
  this.ctx.loadingAnimation = undefined;
@@ -647,9 +661,7 @@ export class EventController {
647
661
  ? "Auto-handoff"
648
662
  : event.action === "shake"
649
663
  ? "Auto-shake"
650
- : event.action === "shake-summary"
651
- ? "Auto-shake (summary)"
652
- : "Auto context-full maintenance";
664
+ : "Auto context-full maintenance";
653
665
  this.ctx.autoCompactionLoader = new Loader(
654
666
  this.ctx.ui,
655
667
  spinner => theme.fg("accent", spinner),
@@ -673,7 +685,7 @@ export class EventController {
673
685
  this.ctx.statusContainer.clear();
674
686
  }
675
687
  const isHandoffAction = event.action === "handoff";
676
- const isShakeAction = event.action === "shake" || event.action === "shake-summary";
688
+ const isShakeAction = event.action === "shake";
677
689
  if (event.aborted) {
678
690
  this.ctx.showStatus(
679
691
  isHandoffAction
@@ -690,9 +702,7 @@ export class EventController {
690
702
  this.ctx.rebuildChatFromMessages();
691
703
  this.ctx.statusLine.invalidate();
692
704
  this.ctx.updateEditorTopBorder();
693
- this.ctx.showStatus(
694
- event.action === "shake-summary" ? "Auto-shake (summary) completed" : "Auto-shake completed",
695
- );
705
+ this.ctx.showStatus("Auto-shake completed");
696
706
  }
697
707
  } else if (event.result) {
698
708
  this.ctx.rebuildChatFromMessages();
@@ -10,6 +10,7 @@ import type {
10
10
  ExtensionError,
11
11
  ExtensionUIContext,
12
12
  ExtensionUIDialogOptions,
13
+ ExtensionUISelectItem,
13
14
  ExtensionUiComponent,
14
15
  ExtensionWidgetContent,
15
16
  ExtensionWidgetOptions,
@@ -483,7 +484,7 @@ export class ExtensionUiController {
483
484
 
484
485
  createBackgroundUiContext(): ExtensionUIContext {
485
486
  return {
486
- select: async (_title: string, _options: string[], _dialogOptions) => undefined,
487
+ select: async (_title: string, _options: ExtensionUISelectItem[], _dialogOptions) => undefined,
487
488
  confirm: async (_title: string, _message: string, _dialogOptions) => false,
488
489
  input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined,
489
490
  notify: () => {},
@@ -581,7 +582,7 @@ export class ExtensionUiController {
581
582
  */
582
583
  showHookSelector(
583
584
  title: string,
584
- options: string[],
585
+ options: ExtensionUISelectItem[],
585
586
  dialogOptions?: ExtensionUIDialogOptions,
586
587
  extra?: { slider?: HookSelectorSlider },
587
588
  ): Promise<string | undefined> {
@@ -86,21 +86,6 @@ export class InputController {
86
86
 
87
87
  setupKeyHandlers(): void {
88
88
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
89
- this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
90
- Boolean(
91
- this.ctx.loadingAnimation ||
92
- this.ctx.hasActiveBtw() ||
93
- this.ctx.hasActiveOmfg() ||
94
- this.ctx.session.isStreaming ||
95
- this.ctx.session.isCompacting ||
96
- this.ctx.session.isGeneratingHandoff ||
97
- this.ctx.session.isBashRunning ||
98
- this.ctx.session.isEvalRunning ||
99
- this.ctx.autoCompactionLoader ||
100
- this.ctx.retryLoader ||
101
- this.ctx.autoCompactionEscapeHandler ||
102
- this.ctx.retryEscapeHandler,
103
- );
104
89
  this.ctx.editor.onEscape = () => {
105
90
  if (this.ctx.loopModeEnabled) {
106
91
  this.ctx.pauseLoop();
@@ -1425,7 +1425,7 @@ export class MCPCommandController {
1425
1425
  this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
1426
1426
 
1427
1427
  try {
1428
- const connection = await this.ctx.mcpManager.reconnectServer(name);
1428
+ const connection = await this.ctx.mcpManager.reconnectServer(name, { manual: true });
1429
1429
  if (connection) {
1430
1430
  // refreshMCPTools re-registers tools and preserves the user's prior
1431
1431
  // MCP tool selection. No need to call activateDiscoveredMCPTools —
@@ -40,6 +40,7 @@ import { isSettingsInitialized, Settings, settings } from "../config/settings";
40
40
  import type {
41
41
  ExtensionUIContext,
42
42
  ExtensionUIDialogOptions,
43
+ ExtensionUISelectItem,
43
44
  ExtensionWidgetContent,
44
45
  ExtensionWidgetOptions,
45
46
  } from "../extensibility/extensions";
@@ -2896,7 +2897,7 @@ export class InteractiveMode implements InteractiveModeContext {
2896
2897
 
2897
2898
  showHookSelector(
2898
2899
  title: string,
2899
- options: string[],
2900
+ options: ExtensionUISelectItem[],
2900
2901
  dialogOptions?: ExtensionUIDialogOptions,
2901
2902
  extra?: { slider?: HookSelectorSlider },
2902
2903
  ): Promise<string | undefined> {
@@ -12,10 +12,12 @@
12
12
  */
13
13
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
- import type {
16
- ExtensionUIContext,
17
- ExtensionUIDialogOptions,
18
- ExtensionWidgetOptions,
15
+ import {
16
+ type ExtensionUIContext,
17
+ type ExtensionUIDialogOptions,
18
+ type ExtensionUISelectItem,
19
+ type ExtensionWidgetOptions,
20
+ getExtensionUISelectOptionLabel,
19
21
  } from "../../extensibility/extensions";
20
22
  import { type Theme, theme } from "../../modes/theme/theme";
21
23
  import type { AgentSession } from "../../session/agent-session";
@@ -256,11 +258,20 @@ export async function runRpcMode(
256
258
  return promise;
257
259
  }
258
260
 
259
- select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
261
+ select(
262
+ title: string,
263
+ options: ExtensionUISelectItem[],
264
+ dialogOptions?: ExtensionUIDialogOptions,
265
+ ): Promise<string | undefined> {
260
266
  return this.#createDialogPromise(
261
267
  dialogOptions,
262
268
  undefined,
263
- { method: "select", title, options, timeout: dialogOptions?.timeout },
269
+ {
270
+ method: "select",
271
+ title,
272
+ options: options.map(getExtensionUISelectOptionLabel),
273
+ timeout: dialogOptions?.timeout,
274
+ },
264
275
  response => parseValueDialogResponse(response, dialogOptions),
265
276
  );
266
277
  }