@oh-my-pi/pi-coding-agent 16.3.11 → 16.3.13

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 (113) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3176 -3087
  3. package/dist/types/advisor/runtime.d.ts +11 -0
  4. package/dist/types/config/keybindings.d.ts +9 -4
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/config/settings-schema.d.ts +6 -0
  7. package/dist/types/config/settings.d.ts +3 -1
  8. package/dist/types/discovery/helpers.d.ts +9 -0
  9. package/dist/types/exec/bash-executor.d.ts +1 -0
  10. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  11. package/dist/types/extensibility/shared-events.d.ts +2 -2
  12. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +1 -0
  13. package/dist/types/internal-urls/registry-helpers.d.ts +7 -5
  14. package/dist/types/mnemopi/state.d.ts +7 -3
  15. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  16. package/dist/types/modes/components/model-selector.d.ts +2 -1
  17. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  18. package/dist/types/modes/github-ref-autocomplete.d.ts +35 -0
  19. package/dist/types/modes/interactive-mode.d.ts +3 -1
  20. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  21. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  22. package/dist/types/modes/types.d.ts +3 -1
  23. package/dist/types/modes/utils/context-usage.d.ts +0 -12
  24. package/dist/types/modes/workflow.d.ts +5 -1
  25. package/dist/types/session/agent-session.d.ts +8 -4
  26. package/dist/types/system-prompt.d.ts +1 -1
  27. package/dist/types/tools/bash-interactive.d.ts +1 -1
  28. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  29. package/dist/types/tools/bash.d.ts +2 -1
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/grep.d.ts +2 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/path-utils.d.ts +24 -0
  34. package/dist/types/tools/read.d.ts +3 -0
  35. package/dist/types/tools/renderers.d.ts +12 -5
  36. package/dist/types/tools/ssh.d.ts +4 -1
  37. package/dist/types/tools/write.d.ts +1 -0
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +145 -0
  41. package/src/advisor/runtime.ts +19 -0
  42. package/src/config/api-key-resolver.ts +7 -2
  43. package/src/config/keybindings.ts +62 -10
  44. package/src/config/model-registry.ts +94 -20
  45. package/src/config/settings-schema.ts +11 -1
  46. package/src/config/settings.ts +59 -21
  47. package/src/discovery/builtin.ts +2 -1
  48. package/src/discovery/claude-plugins.ts +167 -46
  49. package/src/discovery/helpers.ts +16 -1
  50. package/src/edit/renderer.ts +20 -6
  51. package/src/eval/js/worker-core.ts +163 -6
  52. package/src/exec/bash-executor.ts +14 -9
  53. package/src/extensibility/extensions/runner.ts +1 -0
  54. package/src/extensibility/extensions/types.ts +13 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -2
  56. package/src/extensibility/plugins/marketplace/fetcher.ts +15 -14
  57. package/src/extensibility/shared-events.ts +2 -2
  58. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +68 -0
  59. package/src/internal-urls/docs-index.generated.txt +1 -1
  60. package/src/internal-urls/registry-helpers.ts +9 -6
  61. package/src/mnemopi/state.ts +19 -5
  62. package/src/modes/acp/acp-agent.ts +69 -8
  63. package/src/modes/acp/acp-event-mapper.ts +1 -1
  64. package/src/modes/components/model-selector.ts +30 -6
  65. package/src/modes/components/read-tool-group.ts +5 -1
  66. package/src/modes/components/settings-defs.ts +1 -1
  67. package/src/modes/components/status-line/component.ts +14 -2
  68. package/src/modes/components/tool-execution.ts +28 -24
  69. package/src/modes/controllers/command-controller.ts +13 -23
  70. package/src/modes/controllers/event-controller.ts +12 -12
  71. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +7 -35
  73. package/src/modes/controllers/input-controller.ts +23 -57
  74. package/src/modes/controllers/mcp-command-controller.ts +10 -9
  75. package/src/modes/controllers/selector-controller.ts +16 -5
  76. package/src/modes/github-ref-autocomplete.ts +75 -0
  77. package/src/modes/interactive-mode.ts +97 -12
  78. package/src/modes/prompt-action-autocomplete.ts +35 -0
  79. package/src/modes/rpc/rpc-client.ts +42 -13
  80. package/src/modes/rpc/rpc-mode.ts +21 -19
  81. package/src/modes/types.ts +3 -0
  82. package/src/modes/utils/context-usage.ts +58 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +2 -1
  84. package/src/modes/utils/ui-helpers.ts +2 -2
  85. package/src/modes/workflow.ts +14 -8
  86. package/src/prompts/agents/plan.md +0 -1
  87. package/src/prompts/agents/reviewer.md +0 -1
  88. package/src/prompts/system/plan-mode-active.md +5 -2
  89. package/src/prompts/system/system-prompt.md +1 -2
  90. package/src/prompts/system/workflow-notice.md +69 -50
  91. package/src/prompts/tools/bash.md +18 -7
  92. package/src/prompts/tools/grep.md +2 -1
  93. package/src/prompts/tools/memory-edit.md +2 -0
  94. package/src/prompts/tools/read.md +4 -3
  95. package/src/sdk.ts +11 -0
  96. package/src/session/agent-session.ts +136 -19
  97. package/src/system-prompt.ts +3 -2
  98. package/src/tools/bash-interactive.ts +1 -1
  99. package/src/tools/bash-skill-urls.ts +39 -7
  100. package/src/tools/bash.ts +69 -39
  101. package/src/tools/browser/launch.ts +31 -4
  102. package/src/tools/grep.ts +105 -21
  103. package/src/tools/image-gen.ts +1 -1
  104. package/src/tools/index.ts +11 -0
  105. package/src/tools/memory-edit.ts +3 -1
  106. package/src/tools/path-utils.ts +46 -1
  107. package/src/tools/read.ts +135 -57
  108. package/src/tools/renderers.ts +13 -5
  109. package/src/tools/ssh.ts +10 -3
  110. package/src/tools/tts.ts +1 -1
  111. package/src/tools/write.ts +26 -0
  112. package/src/utils/local-date.ts +7 -0
  113. package/src/utils/open.ts +36 -10
@@ -8,6 +8,7 @@ import { ExtensionUiController } from "./extension-ui-controller";
8
8
  function makeHarness() {
9
9
  const editor = new CustomEditor(getEditorTheme());
10
10
  const requestRender = vi.fn();
11
+ const addAutocompleteProvider = vi.fn();
11
12
  let uiContext: ExtensionUIContext | undefined;
12
13
  const ctx = {
13
14
  editor,
@@ -21,11 +22,13 @@ function makeHarness() {
21
22
  expect(hasUI).toBe(true);
22
23
  uiContext = context;
23
24
  },
25
+ addAutocompleteProvider,
24
26
  } as unknown as InteractiveModeContext;
25
27
 
26
28
  return {
27
29
  editor,
28
30
  requestRender,
31
+ addAutocompleteProvider,
29
32
  async init(): Promise<ExtensionUIContext> {
30
33
  await new ExtensionUiController(ctx).initHooksAndCustomTools();
31
34
  expect(uiContext).toBeDefined();
@@ -55,4 +58,17 @@ describe("ExtensionUiController editor UI", () => {
55
58
  expect(harness.editor.getText()).toBe("hello");
56
59
  expect(harness.requestRender).toHaveBeenCalledTimes(1);
57
60
  });
61
+
62
+ it("bridges addAutocompleteProvider factories to the interactive mode context (#4919)", async () => {
63
+ const harness = makeHarness();
64
+ const ui = await harness.init();
65
+
66
+ expect(typeof ui.addAutocompleteProvider).toBe("function");
67
+
68
+ const factory = (current: unknown) => current as never;
69
+ ui.addAutocompleteProvider(factory);
70
+
71
+ expect(harness.addAutocompleteProvider).toHaveBeenCalledTimes(1);
72
+ expect(harness.addAutocompleteProvider).toHaveBeenCalledWith(factory);
73
+ });
58
74
  });
@@ -82,6 +82,7 @@ export class ExtensionUiController {
82
82
  getEditorText: () => this.ctx.editor.getText(),
83
83
  editor: (title, prefill, dialogOptions, editorOptions) =>
84
84
  this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions),
85
+ addAutocompleteProvider: factory => this.ctx.addAutocompleteProvider(factory),
85
86
  get theme() {
86
87
  return theme;
87
88
  },
@@ -162,18 +163,12 @@ export class ExtensionUiController {
162
163
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
163
164
  reload: async () => {
164
165
  await this.ctx.session.reload();
165
- this.ctx.chatContainer.clear();
166
166
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
167
167
  await this.ctx.reloadTodos();
168
168
  this.ctx.showStatus("Reloaded session");
169
169
  },
170
170
  newSession: async options => {
171
- // Stop any loading animation
172
- if (this.ctx.loadingAnimation) {
173
- this.ctx.loadingAnimation.stop();
174
- this.ctx.loadingAnimation = undefined;
175
- }
176
- this.ctx.statusContainer.clear();
171
+ this.ctx.clearTransientSessionUi();
177
172
 
178
173
  // Create new session
179
174
  this.clearExtensionTerminalInputListeners();
@@ -192,15 +187,8 @@ export class ExtensionUiController {
192
187
  // Reset and update status line
193
188
  this.ctx.statusLine.invalidate();
194
189
  this.ctx.statusLine.resetActiveTime();
195
- this.ctx.ui.requestRender();
196
-
197
- // Clear UI state
198
- this.ctx.chatContainer.clear();
199
- this.ctx.pendingMessagesContainer.clear();
200
- this.ctx.compactionQueuedMessages = [];
201
- this.ctx.streamingComponent = undefined;
202
- this.ctx.streamingMessage = undefined;
203
- this.ctx.pendingTools.clear();
190
+ this.ctx.clearTransientSessionUi();
191
+ this.ctx.resetTranscript();
204
192
 
205
193
  this.ctx.present([
206
194
  new Spacer(1),
@@ -218,7 +206,6 @@ export class ExtensionUiController {
218
206
  }
219
207
 
220
208
  // Update UI
221
- this.ctx.chatContainer.clear();
222
209
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
223
210
  await this.ctx.reloadTodos();
224
211
  this.ctx.editor.setText(result.selectedText);
@@ -233,7 +220,6 @@ export class ExtensionUiController {
233
220
  }
234
221
 
235
222
  // Update UI
236
- this.ctx.chatContainer.clear();
237
223
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
238
224
  await this.ctx.reloadTodos();
239
225
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -251,7 +237,6 @@ export class ExtensionUiController {
251
237
  return { cancelled: true };
252
238
  }
253
239
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
254
- this.ctx.chatContainer.clear();
255
240
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
256
241
  await this.ctx.reloadTodos();
257
242
  return { cancelled: false };
@@ -398,18 +383,12 @@ export class ExtensionUiController {
398
383
  waitForIdle: () => this.ctx.session.agent.waitForIdle(),
399
384
  reload: async () => {
400
385
  await this.ctx.session.reload();
401
- this.ctx.chatContainer.clear();
402
386
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
403
387
  await this.ctx.reloadTodos();
404
388
  this.ctx.showStatus("Reloaded session");
405
389
  },
406
390
  newSession: async options => {
407
- // Stop any loading animation
408
- if (this.ctx.loadingAnimation) {
409
- this.ctx.loadingAnimation.stop();
410
- this.ctx.loadingAnimation = undefined;
411
- }
412
- this.ctx.statusContainer.clear();
391
+ this.ctx.clearTransientSessionUi();
413
392
 
414
393
  // Create new session
415
394
  this.clearExtensionTerminalInputListeners();
@@ -425,12 +404,8 @@ export class ExtensionUiController {
425
404
  }
426
405
 
427
406
  // Clear UI state
428
- this.ctx.chatContainer.clear();
429
- this.ctx.pendingMessagesContainer.clear();
430
- this.ctx.compactionQueuedMessages = [];
431
- this.ctx.streamingComponent = undefined;
432
- this.ctx.streamingMessage = undefined;
433
- this.ctx.pendingTools.clear();
407
+ this.ctx.clearTransientSessionUi();
408
+ this.ctx.resetTranscript();
434
409
 
435
410
  this.ctx.present([
436
411
  new Spacer(1),
@@ -448,7 +423,6 @@ export class ExtensionUiController {
448
423
  }
449
424
 
450
425
  // Update UI
451
- this.ctx.chatContainer.clear();
452
426
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
453
427
  await this.ctx.reloadTodos();
454
428
  this.ctx.editor.setText(result.selectedText);
@@ -463,7 +437,6 @@ export class ExtensionUiController {
463
437
  }
464
438
 
465
439
  // Update UI
466
- this.ctx.chatContainer.clear();
467
440
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
468
441
  await this.ctx.reloadTodos();
469
442
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -480,7 +453,6 @@ export class ExtensionUiController {
480
453
  if (!result) {
481
454
  return { cancelled: true };
482
455
  }
483
- this.ctx.chatContainer.clear();
484
456
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
485
457
  await this.ctx.reloadTodos();
486
458
  return { cancelled: false };
@@ -86,6 +86,20 @@ function hasPasteText(value: unknown): value is PasteTarget {
86
86
  return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
87
87
  }
88
88
 
89
+ const SHELL_PROMPT_COMMAND_RE =
90
+ /^(?:\.{0,2}\/|~\/|cd(?:\s|$)|sudo(?:\s|$)|git(?:\s|$)|bun(?:\s|$)|npm(?:\s|$)|pnpm(?:\s|$)|yarn(?:\s|$)|node(?:\s|$)|python\d*(?:\s|$)|cargo(?:\s|$)|go(?:\s|$)|make(?:\s|$)|docker(?:\s|$)|kubectl(?:\s|$))/;
91
+ const SHELL_PROMPT_OPERATOR_RE = /(?:^|\s)(?:&&|\|\||\||2>&1|[<>]{1,2})(?:\s|$)/;
92
+ const OMP_STATUS_LINE_RE = /^\s*in:\s+\d+\s+out:\s+\d+(?:\s+cache\s+\S+)?\s+t:\s+\S+\s+tok\/s:\s+\S+/m;
93
+
94
+ function looksLikePastedShellPrompt(code: string): boolean {
95
+ const firstLine = code.split("\n", 1)[0]?.trimStart() ?? "";
96
+ return (
97
+ SHELL_PROMPT_COMMAND_RE.test(firstLine) ||
98
+ SHELL_PROMPT_OPERATOR_RE.test(firstLine) ||
99
+ OMP_STATUS_LINE_RE.test(code)
100
+ );
101
+ }
102
+
89
103
  function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 {
90
104
  if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0;
91
105
  if (trimmedText.charCodeAt(1) === 123 /* { */) return 0;
@@ -100,8 +114,10 @@ function parsePythonCommandInput(text: string): { code: string; isExcluded: bool
100
114
  const trimmed = text.trimStart();
101
115
  const prefixLength = pythonCommandPrefixLength(trimmed);
102
116
  if (prefixLength === 0) return undefined;
117
+ const code = trimmed.slice(prefixLength).trim();
118
+ if (prefixLength === 1 && looksLikePastedShellPrompt(code)) return undefined;
103
119
  return {
104
- code: trimmed.slice(prefixLength).trim(),
120
+ code,
105
121
  isExcluded: prefixLength === 2,
106
122
  };
107
123
  }
@@ -138,7 +154,6 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
138
154
  // deliberate human double-tap is always tens of milliseconds apart.
139
155
  const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
140
156
  const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
141
- const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
142
157
 
143
158
  export class InputController {
144
159
  constructor(
@@ -163,16 +178,6 @@ export class InputController {
163
178
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
164
179
  // #detectLeftDoubleTap.
165
180
  #leftTapCount = 0;
166
- // Streaming turns use a two-step Esc: first press arms this token, second press
167
- // within the window aborts the same live assistant turn. The token is a per-turn
168
- // sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
169
- // (see setupKeyHandlers), so it survives `message_start`/`message_update`
170
- // transitions inside a single turn but cannot leak across turn boundaries.
171
- #streamingEscapeTurnSentinel: object | undefined;
172
- #streamingEscapeArmedToken: object | undefined;
173
- #streamingEscapeArmedUntil = 0;
174
- #streamingEscapeTimer: NodeJS.Timeout | undefined;
175
- #streamingEscapeSessionSubscribed = false;
176
181
  // Sequential index for `local://attachment-N` references created by large-paste and
177
182
  // pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
178
183
  #attachmentCounter = 0;
@@ -222,50 +227,12 @@ export class InputController {
222
227
  const unsubscribe = tinyTitleClient.onProgress(update);
223
228
  }
224
229
 
225
- #clearStreamingEscapeArm(): void {
226
- this.#streamingEscapeArmedToken = undefined;
227
- this.#streamingEscapeArmedUntil = 0;
228
- if (this.#streamingEscapeTimer) {
229
- clearTimeout(this.#streamingEscapeTimer);
230
- this.#streamingEscapeTimer = undefined;
231
- }
232
- }
233
-
234
- #handleStreamingEscape(): void {
235
- if (!this.#streamingEscapeTurnSentinel) {
236
- this.#streamingEscapeTurnSentinel = {};
237
- }
238
- const token = this.#streamingEscapeTurnSentinel;
239
- const now = Date.now();
240
- if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
241
- this.#clearStreamingEscapeArm();
242
- void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
243
- return;
244
- }
245
-
246
- this.#clearStreamingEscapeArm();
247
- this.#streamingEscapeArmedToken = token;
248
- this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
249
- this.#streamingEscapeTimer = setTimeout(() => {
250
- if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
251
- this.#clearStreamingEscapeArm();
252
- }
253
- }, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
254
- this.#streamingEscapeTimer.unref?.();
255
- this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
230
+ #abortStreamingTurn(): void {
231
+ void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
256
232
  }
257
233
 
258
234
  setupKeyHandlers(): void {
259
235
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
260
- if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
261
- this.#streamingEscapeSessionSubscribed = true;
262
- this.ctx.session.subscribe(event => {
263
- if (event.type === "agent_start" || event.type === "agent_end") {
264
- this.#streamingEscapeTurnSentinel = undefined;
265
- this.#clearStreamingEscapeArm();
266
- }
267
- });
268
- }
269
236
  if (!this.#focusedLeftTapListenerInstalled) {
270
237
  this.#focusedLeftTapListenerInstalled = true;
271
238
  this.ctx.ui.addInputListener(data => {
@@ -335,7 +302,7 @@ export class InputController {
335
302
  if (this.ctx.loopModeEnabled) {
336
303
  this.ctx.pauseLoop();
337
304
  if (this.ctx.session.isStreaming) {
338
- this.#handleStreamingEscape();
305
+ this.#abortStreamingTurn();
339
306
  } else {
340
307
  this.ctx.cancelPendingSubmission();
341
308
  }
@@ -386,11 +353,10 @@ export class InputController {
386
353
  this.ctx.isPythonMode = false;
387
354
  this.ctx.updateEditorBorderColor();
388
355
  } else if (this.ctx.session.isStreaming) {
389
- this.#handleStreamingEscape();
356
+ this.#abortStreamingTurn();
390
357
  } else if (this.ctx.editor.getText().trim()) {
391
- // Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc.
358
+ // Esc must not destroy an in-progress draft.
392
359
  this.ctx.lastEscapeTime = 0;
393
- this.#clearStreamingEscapeArm();
394
360
  } else if (vocalizer.isSpeaking()) {
395
361
  // TTS buffers seconds of PCM past the streaming abort, so an Esc
396
362
  // arriving after the model stopped would otherwise fall through to
@@ -536,7 +502,7 @@ export class InputController {
536
502
  const wasPythonMode = this.ctx.isPythonMode;
537
503
  const trimmed = text.trimStart();
538
504
  this.ctx.isBashMode = trimmed.startsWith("!");
539
- this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0;
505
+ this.ctx.isPythonMode = parsePythonCommandInput(trimmed) !== undefined;
540
506
  if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
541
507
  this.ctx.updateEditorBorderColor();
542
508
  }
@@ -88,12 +88,14 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
88
88
  const MCP_AUTH_MIN_WRAP_WIDTH = 16;
89
89
 
90
90
  /**
91
- * Wrap `url` into rows that each fit inside `width`, prefixed by a shared
92
- * single-column indent so nested composition doesn't touch column 0. When the
93
- * label + URL fit on one line, returns a single row; otherwise puts the label
94
- * on its own row and slices the URL into fixed-width chunks. URL chunks are
95
- * plain code points browsers strip whitespace when pasted into the address
96
- * bar, so a multi-row selection copies back to the intact URL.
91
+ * Wrap `url` into rows that each fit inside `width`. When the label + URL fit
92
+ * on one line, returns a single indented row; otherwise puts the label on its
93
+ * own indented row and slices the URL into fixed-width chunks that start at
94
+ * column 0. Continuation chunks carry ZERO leading bytes on purpose: a
95
+ * multi-row terminal selection includes the newline plus any leading indent,
96
+ * and while address bars strip newlines they preserve or percent-encode
97
+ * embedded spaces — an indent would corrupt the URL at every chunk boundary
98
+ * (silently, when the damage lands inside a query value).
97
99
  */
98
100
  function wrapUrlRows(label: string, url: string, width: number): string[] {
99
101
  const indent = " ";
@@ -103,10 +105,9 @@ function wrapUrlRows(label: string, url: string, width: number): string[] {
103
105
  if (inlineWidth <= effective) {
104
106
  return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
105
107
  }
106
- const chunkWidth = Math.max(1, effective - indent.length);
107
108
  const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
108
- for (let i = 0; i < sanitized.length; i += chunkWidth) {
109
- rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
109
+ for (let i = 0; i < sanitized.length; i += effective) {
110
+ rows.push(theme.fg("muted", sanitized.slice(i, i + effective)));
110
111
  }
111
112
  return rows;
112
113
  }
@@ -593,13 +593,27 @@ export class SelectorController {
593
593
  this.ctx.settings,
594
594
  this.ctx.session.modelRegistry,
595
595
  this.ctx.session.scopedModels,
596
- async (model, role, thinkingLevel, selector) => {
596
+ async (model, role, thinkingLevel, selector, action) => {
597
597
  // `auto` is session-global: never baked into a per-role model value
598
598
  // (it can't round-trip through `model:<level>`). Apply it to the session
599
599
  // separately and persist via `defaultThinkingLevel`.
600
600
  const isAuto = thinkingLevel === AUTO_THINKING;
601
601
  const concreteThinking = isAuto ? undefined : thinkingLevel;
602
+ const selectorValue = selector ?? `${model.provider}/${model.id}`;
602
603
  try {
604
+ if (action === "retryFallback" && role !== null) {
605
+ const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
606
+ const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
607
+ const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
608
+ this.ctx.settings.set("retry.fallbackChains", {
609
+ ...fallbackChains,
610
+ [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
611
+ });
612
+ const roleInfo = getRoleInfo(role, settings);
613
+ const roleLabel = roleInfo?.name ?? role;
614
+ this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
615
+ return;
616
+ }
603
617
  if (role === null) {
604
618
  // Temporary: update agent state but don't persist the model to settings
605
619
  await this.ctx.session.setModelTemporary(model);
@@ -771,7 +785,6 @@ export class SelectorController {
771
785
  return;
772
786
  }
773
787
 
774
- this.ctx.chatContainer.clear();
775
788
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
776
789
  this.ctx.editor.setText(result.selectedText);
777
790
  done();
@@ -915,7 +928,6 @@ export class SelectorController {
915
928
 
916
929
  // Update UI — rebuild the display transcript for the new leaf (the
917
930
  // context from navigateTree is the LLM context, not the transcript).
918
- this.ctx.chatContainer.clear();
919
931
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
920
932
  await this.ctx.reloadTodos();
921
933
  if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -927,7 +939,7 @@ export class SelectorController {
927
939
  } finally {
928
940
  if (summaryLoader) {
929
941
  summaryLoader.stop();
930
- this.ctx.statusContainer.clear();
942
+ this.ctx.statusContainer.disposeChildren();
931
943
  }
932
944
  this.ctx.editor.onEscape = originalOnEscape;
933
945
  }
@@ -1067,7 +1079,6 @@ export class SelectorController {
1067
1079
  this.ctx.updateEditorBorderColor();
1068
1080
 
1069
1081
  // Clear and re-render the chat
1070
- this.ctx.chatContainer.clear();
1071
1082
  this.ctx.renderInitialMessages({ clearTerminalHistory: true });
1072
1083
  await this.ctx.reloadTodos();
1073
1084
  this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
3
+ *
4
+ * Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
5
+ * token is rewritten to an internal URL (`pr://3164` or `issue://3164`) plus a
6
+ * trailing space, and the existing tool-mediated pipeline (the `read` tool →
7
+ * InternalUrlRouter → `gh`) resolves it from the session cwd's git remote.
8
+ *
9
+ * No network at suggestion time — candidates are generated locally. GitHub
10
+ * shares the issue/PR number space and there is no cheap way to tell which a
11
+ * given number is while typing, so both a PR and an Issue candidate are offered
12
+ * by default. Naming the type first (`pr #3164` / `issue #3164`) constrains the
13
+ * candidates to that kind. Anything that is not a standalone `#<number>` token
14
+ * keeps falling through to the existing prompt-action menu.
15
+ */
16
+ import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
17
+
18
+ /** Candidate kinds, in default display order. */
19
+ const GITHUB_REF_KINDS = [
20
+ { qualifier: "pr", scheme: "pr", label: "PR", description: "GitHub pull request" },
21
+ { qualifier: "issue", scheme: "issue", label: "Issue", description: "GitHub issue" },
22
+ ] as const;
23
+
24
+ export interface GithubRefContext {
25
+ /** Text to replace on accept: `#3164`, or `pr #3164` when a qualifier precedes it. */
26
+ prefix: string;
27
+ /** Type the user named (`pr`/`pull` → `pr`, `issue` → `issue`), or null to offer both. */
28
+ qualifier: "pr" | "issue" | null;
29
+ /** The numeric reference, e.g. `3164`. */
30
+ number: string;
31
+ }
32
+
33
+ /**
34
+ * A standalone `#<positive-number>` token ending at the cursor. The `#` must be
35
+ * preceded by a token boundary (start, whitespace, or an opening quote/paren/`<`/`=`,
36
+ * matching the internal-URL boundary set) so embedded hashes like `owner/repo#N`,
37
+ * `foo#N`, `C#12`, or a URL fragment do not match. An optional `pr`/`pull`/`issue`
38
+ * qualifier word (case-insensitive) immediately before the `#` constrains the kind.
39
+ */
40
+ const GITHUB_REF_TOKEN_RE = /(?:^|[\s"'`(<=])(?:(pr|pull|issue)(\s+))?#([1-9]\d*)$/i;
41
+
42
+ export function getGithubRefContext(textBeforeCursor: string): GithubRefContext | null {
43
+ const match = textBeforeCursor.match(GITHUB_REF_TOKEN_RE);
44
+ if (!match) return null;
45
+ const qualifierWord = match[1];
46
+ const whitespace = match[2] ?? "";
47
+ const number = match[3] ?? "";
48
+ return {
49
+ prefix: qualifierWord ? `${qualifierWord}${whitespace}#${number}` : `#${number}`,
50
+ qualifier: !qualifierWord ? null : qualifierWord.toLowerCase() === "issue" ? "issue" : "pr",
51
+ number,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Suggestions for a `#<number>` token. Both kinds are offered unless the user
57
+ * named a type (`pr #3164` / `issue #3164`), in which case only that kind is
58
+ * offered. Returns `null` when the text before the cursor is not a standalone
59
+ * `#<number>` token.
60
+ */
61
+ export function getGithubRefSuggestions(
62
+ textBeforeCursor: string,
63
+ ): { items: AutocompleteItem[]; prefix: string } | null {
64
+ const context = getGithubRefContext(textBeforeCursor);
65
+ if (!context) return null;
66
+ const kinds = context.qualifier
67
+ ? GITHUB_REF_KINDS.filter(kind => kind.qualifier === context.qualifier)
68
+ : GITHUB_REF_KINDS;
69
+ const items: AutocompleteItem[] = kinds.map(kind => ({
70
+ value: `${kind.scheme}://${context.number}`,
71
+ label: `${kind.label} #${context.number}`,
72
+ description: kind.description,
73
+ }));
74
+ return { items, prefix: context.prefix };
75
+ }
@@ -16,6 +16,7 @@ import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
16
16
  import type { AssistantMessage, ImageContent, Message, Model, Usage, UsageReport } from "@oh-my-pi/pi-ai";
17
17
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
18
18
  import type {
19
+ AutocompleteProvider,
19
20
  Component,
20
21
  EditorTheme,
21
22
  LoaderMessageColorFn,
@@ -59,6 +60,7 @@ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
59
60
  import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
60
61
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
61
62
  import type {
63
+ AutocompleteProviderFactory,
62
64
  ContextUsage,
63
65
  ExtensionUIContext,
64
66
  ExtensionUIDialogOptions,
@@ -516,6 +518,10 @@ export class InteractiveMode implements InteractiveModeContext {
516
518
  collabGuest?: CollabGuestLink;
517
519
 
518
520
  #pendingSlashCommands: SlashCommand[] = [];
521
+ /** Built-in editor autocomplete provider, before extension wrapping. */
522
+ #baseAutocompleteProvider: AutocompleteProvider | undefined;
523
+ /** Extension-registered provider factories, applied in registration order (#4919). */
524
+ #autocompleteProviderFactories: AutocompleteProviderFactory[] = [];
519
525
  #cleanupUnsubscribe?: () => void;
520
526
  #signalTeardown?: SessionTeardown;
521
527
  readonly #version: string;
@@ -579,10 +585,10 @@ export class InteractiveMode implements InteractiveModeContext {
579
585
  this.retryLoader.stop();
580
586
  this.retryLoader = undefined;
581
587
  }
582
- this.statusContainer.clear();
583
- this.pendingMessagesContainer.clear();
588
+ this.statusContainer.disposeChildren();
589
+ this.pendingMessagesContainer.disposeChildren();
584
590
  this.#cancelModelCycleClearTimer();
585
- this.modelCycleContainer.clear();
591
+ this.modelCycleContainer.disposeChildren();
586
592
  this.compactionQueuedMessages = [];
587
593
  this.streamingComponent = undefined;
588
594
  this.streamingMessage = undefined;
@@ -1094,14 +1100,49 @@ export class InteractiveMode implements InteractiveModeContext {
1094
1100
  // source suffix (e.g. "Review code (project)"), so pass it through verbatim.
1095
1101
  description: template.description,
1096
1102
  }));
1097
- const autocompleteProvider = this.#inputController.createAutocompleteProvider(
1103
+ this.#baseAutocompleteProvider = this.#inputController.createAutocompleteProvider(
1098
1104
  [...this.#pendingSlashCommands, ...fileSlashCommands, ...promptTemplateCommands],
1099
1105
  basePath,
1100
1106
  );
1101
- this.editor.setAutocompleteProvider(autocompleteProvider);
1107
+ this.#applyAutocompleteProvider();
1102
1108
  this.session.setSlashCommands(fileCommands);
1103
1109
  }
1104
1110
 
1111
+ /**
1112
+ * Rebuild the editor's autocomplete provider: the built-in provider wrapped
1113
+ * by every extension-registered factory, in registration order. A factory
1114
+ * that throws or returns a malformed provider is skipped so one broken
1115
+ * extension cannot take down core autocomplete.
1116
+ */
1117
+ #applyAutocompleteProvider(): void {
1118
+ const base = this.#baseAutocompleteProvider;
1119
+ if (!base) return;
1120
+ let provider = base;
1121
+ for (const factory of this.#autocompleteProviderFactories) {
1122
+ try {
1123
+ const wrapped = factory(provider);
1124
+ if (
1125
+ wrapped &&
1126
+ typeof wrapped.getSuggestions === "function" &&
1127
+ typeof wrapped.applyCompletion === "function"
1128
+ ) {
1129
+ provider = wrapped;
1130
+ } else {
1131
+ logger.warn("Extension autocomplete provider factory returned an invalid provider; skipping it");
1132
+ }
1133
+ } catch (error) {
1134
+ logger.warn("Extension autocomplete provider factory threw; skipping it", { error: String(error) });
1135
+ }
1136
+ }
1137
+ this.editor.setAutocompleteProvider(provider);
1138
+ }
1139
+
1140
+ /** Stack extension autocomplete behavior on top of the built-in editor provider (#4919). */
1141
+ addAutocompleteProvider(factory: AutocompleteProviderFactory): void {
1142
+ this.#autocompleteProviderFactories.push(factory);
1143
+ this.#applyAutocompleteProvider();
1144
+ }
1145
+
1105
1146
  /**
1106
1147
  * Re-point the process and every cwd-derived cache at `newCwd` after the
1107
1148
  * active session's working directory changed (`/move` relocation or resuming
@@ -2602,6 +2643,49 @@ export class InteractiveMode implements InteractiveModeContext {
2602
2643
  }
2603
2644
  }
2604
2645
 
2646
+ #resolveLocalRoot(): string {
2647
+ return resolveLocalUrlToPath("local://", {
2648
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2649
+ getSessionId: () => this.sessionManager.getSessionId(),
2650
+ });
2651
+ }
2652
+
2653
+ async #copyLocalArtifactsForFreshSession(sourceRoot: string, destinationRoot: string): Promise<void> {
2654
+ if (sourceRoot === destinationRoot) return;
2655
+
2656
+ let sourceRootStat: { isDirectory(): boolean };
2657
+ try {
2658
+ sourceRootStat = await fs.lstat(sourceRoot);
2659
+ } catch (error) {
2660
+ if (isEnoent(error)) return;
2661
+ throw error;
2662
+ }
2663
+
2664
+ if (!sourceRootStat.isDirectory()) return;
2665
+
2666
+ await fs.mkdir(destinationRoot, { recursive: true });
2667
+ await this.#copyLocalArtifactEntries(sourceRoot, destinationRoot);
2668
+ }
2669
+
2670
+ async #copyLocalArtifactEntries(sourceDir: string, destinationDir: string): Promise<void> {
2671
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
2672
+ for (const entry of entries) {
2673
+ const sourcePath = path.join(sourceDir, entry.name);
2674
+ const destinationPath = path.join(destinationDir, entry.name);
2675
+
2676
+ if (entry.isDirectory()) {
2677
+ await fs.mkdir(destinationPath, { recursive: true });
2678
+ await this.#copyLocalArtifactEntries(sourcePath, destinationPath);
2679
+ continue;
2680
+ }
2681
+
2682
+ if (entry.isFile()) {
2683
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
2684
+ await fs.copyFile(sourcePath, destinationPath);
2685
+ }
2686
+ }
2687
+ }
2688
+
2605
2689
  async #approvePlan(
2606
2690
  planContent: string,
2607
2691
  options: {
@@ -2632,14 +2716,16 @@ export class InteractiveMode implements InteractiveModeContext {
2632
2716
  });
2633
2717
 
2634
2718
  if (!options.preserveContext) {
2719
+ const oldLocalRoot = this.#resolveLocalRoot();
2635
2720
  await this.handleClearCommand();
2636
- // The new session has a fresh local:// root — persist the approved plan there
2637
- // so `local://<slug>-plan.md` resolves correctly in the execution session.
2721
+ const newLocalRoot = this.#resolveLocalRoot();
2722
+ await this.#copyLocalArtifactsForFreshSession(oldLocalRoot, newLocalRoot);
2638
2723
  const newLocalPath = resolveLocalUrlToPath(options.planFilePath, {
2639
2724
  getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
2640
2725
  getSessionId: () => this.sessionManager.getSessionId(),
2641
2726
  });
2642
- await Bun.write(newLocalPath, planContent);
2727
+ await fs.mkdir(path.dirname(newLocalPath), { recursive: true });
2728
+ await fs.writeFile(newLocalPath, planContent);
2643
2729
  } else if (options.compactBeforeExecute) {
2644
2730
  // Distill the plan-mode transcript before the execution turn is queued so
2645
2731
  // the plan-approved synthetic prompt lands as a fresh cache anchor.
@@ -3626,7 +3712,7 @@ export class InteractiveMode implements InteractiveModeContext {
3626
3712
  ensureLoadingAnimation(): void {
3627
3713
  if (!this.loadingAnimation) {
3628
3714
  this.#clearWorkingMessageAccentCache();
3629
- this.statusContainer.clear();
3715
+ this.statusContainer.disposeChildren();
3630
3716
  const messageColorFn = ((message: string) =>
3631
3717
  renderWorkingMessage(message, this.#getWorkingMessageAccent())) as LoaderMessageColorFn & {
3632
3718
  animated?: true;
@@ -3647,7 +3733,7 @@ export class InteractiveMode implements InteractiveModeContext {
3647
3733
  );
3648
3734
  this.statusContainer.addChild(this.loadingAnimation);
3649
3735
  } else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
3650
- this.statusContainer.clear();
3736
+ this.statusContainer.disposeChildren();
3651
3737
  this.statusContainer.addChild(this.loadingAnimation);
3652
3738
  this.ui.requestRender();
3653
3739
  }
@@ -3660,7 +3746,7 @@ export class InteractiveMode implements InteractiveModeContext {
3660
3746
  this.loadingAnimation = undefined;
3661
3747
  this.#clearWorkingMessageAccentCache();
3662
3748
  if (clearStatusContainer) {
3663
- this.statusContainer.clear();
3749
+ this.statusContainer.disposeChildren();
3664
3750
  }
3665
3751
  }
3666
3752
 
@@ -4123,7 +4209,6 @@ export class InteractiveMode implements InteractiveModeContext {
4123
4209
  }
4124
4210
  this.#btwController.dispose();
4125
4211
  this.#omfgController.dispose();
4126
- this.chatContainer.clear();
4127
4212
  this.renderInitialMessages({ clearTerminalHistory: true });
4128
4213
  this.updateEditorBorderColor();
4129
4214
  this.showStatus(