@oh-my-pi/pi-coding-agent 16.3.12 → 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 (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli.js +3244 -3246
  3. package/dist/types/config/keybindings.d.ts +9 -4
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/extensibility/extensions/types.d.ts +11 -2
  6. package/dist/types/mnemopi/state.d.ts +7 -3
  7. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  8. package/dist/types/modes/components/read-tool-group.d.ts +1 -0
  9. package/dist/types/modes/interactive-mode.d.ts +3 -1
  10. package/dist/types/modes/rpc/rpc-client.d.ts +11 -5
  11. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  12. package/dist/types/modes/types.d.ts +3 -1
  13. package/dist/types/session/agent-session.d.ts +3 -4
  14. package/dist/types/tools/read.d.ts +1 -0
  15. package/dist/types/tools/renderers.d.ts +12 -5
  16. package/dist/types/tools/ssh.d.ts +4 -1
  17. package/dist/types/tools/write.d.ts +1 -0
  18. package/package.json +12 -12
  19. package/src/config/keybindings.ts +62 -10
  20. package/src/config/model-registry.ts +85 -20
  21. package/src/config/settings.ts +48 -21
  22. package/src/extensibility/extensions/runner.ts +1 -0
  23. package/src/extensibility/extensions/types.ts +13 -2
  24. package/src/internal-urls/docs-index.generated.txt +1 -1
  25. package/src/mnemopi/state.ts +19 -5
  26. package/src/modes/acp/acp-agent.ts +69 -8
  27. package/src/modes/acp/acp-event-mapper.ts +1 -1
  28. package/src/modes/components/read-tool-group.ts +5 -1
  29. package/src/modes/components/tool-execution.ts +28 -24
  30. package/src/modes/controllers/extension-ui-controller.test.ts +16 -0
  31. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  32. package/src/modes/controllers/input-controller.ts +5 -55
  33. package/src/modes/interactive-mode.ts +43 -2
  34. package/src/modes/rpc/rpc-client.ts +42 -13
  35. package/src/modes/rpc/rpc-mode.ts +21 -19
  36. package/src/modes/types.ts +3 -0
  37. package/src/prompts/agents/plan.md +0 -1
  38. package/src/prompts/agents/reviewer.md +0 -1
  39. package/src/prompts/tools/grep.md +2 -1
  40. package/src/prompts/tools/memory-edit.md +2 -0
  41. package/src/session/agent-session.ts +8 -5
  42. package/src/tools/grep.ts +58 -13
  43. package/src/tools/image-gen.ts +1 -1
  44. package/src/tools/memory-edit.ts +3 -1
  45. package/src/tools/read.ts +33 -13
  46. package/src/tools/renderers.ts +13 -5
  47. package/src/tools/ssh.ts +10 -3
  48. package/src/tools/tts.ts +1 -1
  49. package/src/tools/write.ts +26 -0
@@ -108,11 +108,16 @@ export interface MnemopiMemoryEditOptions {
108
108
  }
109
109
 
110
110
  export interface MnemopiMemoryEditResult {
111
- status: "updated" | "deleted" | "invalidated" | "not_found";
111
+ status: "updated" | "deleted" | "invalidated" | "not_found" | "not_editable";
112
112
  bank?: string;
113
- store?: "working" | "episodic";
113
+ store?: MnemopiMemoryStore;
114
114
  }
115
115
 
116
+ /** Which mnemopi table a resolved memory id lives in. `fact` rows are
117
+ * read-only projections of fact extraction (issue #4725): resolvable for
118
+ * reads, never editable. */
119
+ export type MnemopiMemoryStore = "working" | "episodic" | "fact";
120
+
116
121
  interface MnemopiStoredMemoryRow {
117
122
  id?: unknown;
118
123
  content?: unknown;
@@ -136,7 +141,7 @@ interface MnemopiStoredMemoryRow {
136
141
  */
137
142
  export interface MnemopiScopedMemoryHit {
138
143
  bank: string;
139
- store: "working" | "episodic";
144
+ store: MnemopiMemoryStore;
140
145
  row: {
141
146
  id: string;
142
147
  content: string;
@@ -256,7 +261,8 @@ export class MnemopiSessionState {
256
261
  for (const target of targets) {
257
262
  const raw = target.memory.get(id) as MnemopiStoredMemoryRow | null;
258
263
  if (!raw) continue;
259
- const store: MnemopiScopedMemoryHit["store"] = raw.memory_store === "episodic" ? "episodic" : "working";
264
+ const store: MnemopiMemoryStore =
265
+ raw.memory_store === "episodic" || raw.memory_store === "fact" ? raw.memory_store : "working";
260
266
  return {
261
267
  bank: target.bank,
262
268
  store,
@@ -291,8 +297,16 @@ export class MnemopiSessionState {
291
297
  for (const target of targets) {
292
298
  const row = target.memory.get(id) as MnemopiStoredMemoryRow | null;
293
299
  if (!row) continue;
294
- const store: MnemopiMemoryEditResult["store"] = row.memory_store === "episodic" ? "episodic" : "working";
300
+ const store: MnemopiMemoryStore =
301
+ row.memory_store === "episodic" || row.memory_store === "fact" ? row.memory_store : "working";
295
302
  const resultContext: Pick<MnemopiMemoryEditResult, "bank" | "store"> = { bank: target.bank, store };
303
+ if (store === "fact") {
304
+ // Facts are read-only: no memory_edit op mutates the facts
305
+ // table, so report that precisely instead of `not_found`
306
+ // (the id DID resolve — issue #4725).
307
+ ineligible ??= { status: "not_editable", ...resultContext };
308
+ continue;
309
+ }
296
310
  if ((op === "update" || op === "forget") && store !== "working") {
297
311
  ineligible ??= { status: "not_found", ...resultContext };
298
312
  continue;
@@ -84,6 +84,7 @@ import { canonicalizeMessage } from "../../utils/thinking-display";
84
84
  import { createAcpClientBridge } from "./acp-client-bridge";
85
85
  import {
86
86
  buildToolCallStartUpdate,
87
+ extractAssistantMessageText,
87
88
  mapAgentSessionEventToAcpSessionUpdates,
88
89
  normalizeReplayToolArguments,
89
90
  } from "./acp-event-mapper";
@@ -425,6 +426,7 @@ export function createAcpExtensionUiContext(
425
426
  setEditorText: () => {},
426
427
  getEditorText: () => "",
427
428
  editor: async () => undefined,
429
+ addAutocompleteProvider: () => {},
428
430
  setEditorComponent: () => {},
429
431
  get theme() {
430
432
  return theme;
@@ -843,13 +845,16 @@ export class AcpAgent implements Agent {
843
845
  return false;
844
846
  }
845
847
  const built = await buildSkillPromptMessage(skill, parsed.args, "user");
846
- await record.session.promptCustomMessage({
847
- customType: SKILL_PROMPT_MESSAGE_TYPE,
848
- content: built.message,
849
- display: true,
850
- details: built.details,
851
- attribution: "user",
852
- });
848
+ await record.session.promptCustomMessage(
849
+ {
850
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
851
+ content: built.message,
852
+ display: true,
853
+ details: built.details,
854
+ attribution: "user",
855
+ },
856
+ { streamingBehavior: "steer" },
857
+ );
853
858
  return true;
854
859
  }
855
860
 
@@ -1210,8 +1215,11 @@ export class AcpAgent implements Agent {
1210
1215
  this.#clearLiveAssistantMessageAfterEvent(record, event);
1211
1216
 
1212
1217
  if (event.type === "agent_end") {
1218
+ await this.#flushMissedFinalAssistantText(record, event);
1213
1219
  await this.#emitEndOfTurnUpdates(record);
1214
1220
  await this.#waitForAcpPromptIdle(record);
1221
+ record.liveMessageId = undefined;
1222
+ record.liveMessageProgress = undefined;
1215
1223
  this.#finishPrompt(record, {
1216
1224
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
1217
1225
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -1219,6 +1227,51 @@ export class AcpAgent implements Agent {
1219
1227
  }
1220
1228
  }
1221
1229
 
1230
+ /**
1231
+ * Deliver the final visible answer when the assistant `message_end` never
1232
+ * reached this prompt turn's subscription. Session event handlers are
1233
+ * fire-and-forget (`Agent#emit` does not await async listeners), and
1234
+ * `agent_end` is flushed through the session's `#endInFlight` path while the
1235
+ * assistant `message_end` fan-out can still be parked on extension delivery —
1236
+ * so `agent_end` can overtake `message_end`. Once the turn finishes,
1237
+ * `#finishPrompt` unsubscribes and the fallback text emission in
1238
+ * `mapAssistantMessageEnd` is lost for good: a client that only received
1239
+ * `agent_thought_chunk`s stays stuck on the thinking block (#4902). The live
1240
+ * message progress records whether visible text ever reached the client; if
1241
+ * it has not, emit the last assistant message's text before the prompt
1242
+ * resolves. A `message_end` that lands during the end-of-turn waits still
1243
+ * takes the normal mapper path and sees `textEmitted` already set, so the
1244
+ * answer is delivered exactly once.
1245
+ */
1246
+ async #flushMissedFinalAssistantText(
1247
+ record: ManagedSessionRecord,
1248
+ event: Extract<AgentSessionEvent, { type: "agent_end" }>,
1249
+ ): Promise<void> {
1250
+ const progress = record.liveMessageProgress;
1251
+ if (!progress || progress.textEmitted) {
1252
+ return;
1253
+ }
1254
+ const lastAssistant = [...event.messages]
1255
+ .reverse()
1256
+ .find((message): message is AssistantMessage => message.role === "assistant");
1257
+ if (!lastAssistant) {
1258
+ return;
1259
+ }
1260
+ const text = extractAssistantMessageText(lastAssistant);
1261
+ if (text.length === 0) {
1262
+ return;
1263
+ }
1264
+ progress.textEmitted = true;
1265
+ await this.#connection.sessionUpdate({
1266
+ sessionId: record.session.sessionId,
1267
+ update: {
1268
+ sessionUpdate: "agent_message_chunk",
1269
+ content: { type: "text", text },
1270
+ messageId: record.liveMessageId,
1271
+ },
1272
+ });
1273
+ }
1274
+
1222
1275
  async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1223
1276
  for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1224
1277
  await record.session.waitForIdle();
@@ -1244,8 +1297,16 @@ export class AcpAgent implements Agent {
1244
1297
  }
1245
1298
  }
1246
1299
 
1300
+ /**
1301
+ * Reset live-message tracking once the assistant `message_end` is handled.
1302
+ * The `agent_end` reset happens inside the `agent_end` branch of
1303
+ * `#handlePromptEvent` — after `#flushMissedFinalAssistantText` — so a
1304
+ * `message_end` that arrives during the end-of-turn waits maps against the
1305
+ * real progress instead of resurrecting a fresh one (which would double-emit
1306
+ * the final answer).
1307
+ */
1247
1308
  #clearLiveAssistantMessageAfterEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
1248
- if ((event.type === "message_end" && event.message.role === "assistant") || event.type === "agent_end") {
1309
+ if (event.type === "message_end" && event.message.role === "assistant") {
1249
1310
  record.liveMessageId = undefined;
1250
1311
  record.liveMessageProgress = undefined;
1251
1312
  }
@@ -922,7 +922,7 @@ function isTerminalOnlyDetails(value: unknown): boolean {
922
922
  return content === undefined || (Array.isArray(content) && content.length === 0);
923
923
  }
924
924
 
925
- function extractAssistantMessageText(value: unknown): string {
925
+ export function extractAssistantMessageText(value: unknown): string {
926
926
  if (typeof value !== "object" || value === null || !("content" in value)) {
927
927
  return "";
928
928
  }
@@ -37,6 +37,7 @@ export function readArgsTargetInternalUrl(args: unknown): boolean {
37
37
  type ReadRenderArgs = {
38
38
  path?: string;
39
39
  file_path?: string;
40
+ selector?: string;
40
41
  // Legacy field from the old schema; tolerated for rebuilt transcripts.
41
42
  sel?: string;
42
43
  };
@@ -344,7 +345,10 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
344
345
  updateArgs(args: ReadRenderArgs, toolCallId?: string): void {
345
346
  if (!toolCallId) return;
346
347
  const basePath = args.file_path || args.path || "";
347
- const rawPath = args.sel ? `${basePath}:${args.sel}` : basePath;
348
+ const rawSelector =
349
+ typeof args.selector === "string" ? args.selector : typeof args.sel === "string" ? args.sel : undefined;
350
+ const selector = rawSelector?.trim().replace(/^:+/, "");
351
+ const rawPath = selector && selector.length > 0 ? `${basePath}:${selector}` : basePath;
348
352
  const entry: ReadEntry = this.#entries.get(toolCallId) ?? {
349
353
  toolCallId,
350
354
  path: rawPath,
@@ -38,7 +38,7 @@ import {
38
38
  resolveImageOptions,
39
39
  truncateToWidth,
40
40
  } from "../../tools/render-utils";
41
- import { toolRenderers } from "../../tools/renderers";
41
+ import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
42
42
  import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
43
43
  import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
44
44
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -283,13 +283,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
283
283
  // history, so progress renders static gray and further partial snapshots are
284
284
  // dropped (see #maybeFreezeBackgroundTask).
285
285
  #backgroundTaskFrozen = false;
286
- // Set on each `render()` when the last painted shape carried the streamed
287
- // SSH-style placeholder / partial-result chrome. Reset gates key off these
288
- // so a topology-changing update that lands before the shape reaches the
289
- // terminal never triggers a full-viewport replay (which on direct terminals
290
- // wipes native scrollback and flashes the user's history — reviewer note on
291
- // PR #4315).
292
- #placeholderShapePainted = false;
286
+ // Set on each `render()` when the last painted pending shape must be
287
+ // replayed wholesale when the first result arrives. Reset gates key off
288
+ // these so a topology-changing update that lands before the shape reaches
289
+ // the terminal never triggers a full-viewport replay (which on direct
290
+ // terminals wipes native scrollback and flashes the user's history —
291
+ // reviewer note on PR #4315).
292
+ #firstResultViewportRepaintShapePainted = false;
293
293
  #partialResultShapePainted = false;
294
294
  #renderState: {
295
295
  spinnerFrame?: number;
@@ -497,9 +497,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
497
497
  }
498
498
  const hadNoResult = this.#result === undefined;
499
499
  const wasPartialResult = this.#result !== undefined && this.#isPartial;
500
- const placeholderPainted = this.#placeholderShapePainted;
500
+ const firstResultRepaintShapePainted = this.#firstResultViewportRepaintShapePainted;
501
501
  const partialResultPainted = this.#partialResultShapePainted;
502
- this.#placeholderShapePainted = false;
502
+ this.#firstResultViewportRepaintShapePainted = false;
503
503
  this.#partialResultShapePainted = false;
504
504
  this.#result = result;
505
505
  this.#resultVersion++;
@@ -513,7 +513,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
513
513
  this.#updateTodoStrikeAnimation();
514
514
  this.#updateDisplay();
515
515
  this.#resetDisplayForResultTopologyChange(
516
- hadNoResult && placeholderPainted,
516
+ hadNoResult && firstResultRepaintShapePainted,
517
517
  wasPartialResult && partialResultPainted,
518
518
  isPartial,
519
519
  );
@@ -810,34 +810,38 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
810
810
  this.#displayBuilt = true;
811
811
  }
812
812
 
813
- #rendererFlag(name: "forceFirstResultViewportRepaint" | "forceResultViewportRepaintOnSettle"): boolean {
813
+ #rendererFlag(name: "forceResultViewportRepaintOnSettle"): boolean {
814
814
  const toolValue = (this.#tool as Record<string, unknown> | undefined)?.[name];
815
815
  const rendererValue = toolRenderers[this.#toolName]?.[name];
816
816
  return toolValue === true || (toolValue === undefined && rendererValue === true);
817
817
  }
818
818
 
819
819
  /**
820
- * True while the last painted shape uses the streamed placeholder path
821
- * (`⏳ SSH: […]` / `$ …`) — the render call ran with `__partialJson` args
822
- * and no result. Kept as a per-paint fact so a topology-changing update
823
- * that lands before the placeholder reaches the terminal skips the reset.
820
+ * True while the last painted pending-call shape opted into a full viewport
821
+ * repaint at the first result (`forceFirstResultViewportRepaint`) — e.g. the
822
+ * streamed SSH placeholder (`⏳ SSH: […]` / `$ …`) or a collapsed write tail
823
+ * window, both of which the first result render re-anchors instead of
824
+ * preserving. Kept as a per-paint fact so a topology-changing update that
825
+ * lands before the pending rows reach the terminal skips the reset.
824
826
  */
825
- #isPlaceholderShapeAtRender(): boolean {
827
+ #needsFirstResultViewportRepaintAtRender(): boolean {
826
828
  if (this.#result !== undefined) return false;
827
- if (!this.#rendererFlag("forceFirstResultViewportRepaint")) return false;
828
- return partialJsonOf(this.#args) !== undefined;
829
+ const toolValue = (this.#tool as { forceFirstResultViewportRepaint?: FirstResultViewportRepaint } | undefined)
830
+ ?.forceFirstResultViewportRepaint;
831
+ const value =
832
+ toolValue !== undefined ? toolValue : toolRenderers[this.#toolName]?.forceFirstResultViewportRepaint;
833
+ if (typeof value === "function") return value(this.#args, this.#renderState);
834
+ return value === true;
829
835
  }
830
836
 
831
837
  #resetDisplayForResultTopologyChange(
832
- firstResultAfterPlaceholderPaint: boolean,
838
+ firstResultAfterRepaintShapePaint: boolean,
833
839
  partialResultPaintedBeforeSettle: boolean,
834
840
  isPartial: boolean,
835
841
  ): void {
836
- const firstResultReplacesStreamedPlaceholder =
837
- firstResultAfterPlaceholderPaint && this.#rendererFlag("forceFirstResultViewportRepaint");
838
842
  const provisionalResultSettled =
839
843
  partialResultPaintedBeforeSettle && !isPartial && this.#rendererFlag("forceResultViewportRepaintOnSettle");
840
- if (firstResultReplacesStreamedPlaceholder || provisionalResultSettled) {
844
+ if (firstResultAfterRepaintShapePaint || provisionalResultSettled) {
841
845
  this.#ui.resetDisplay();
842
846
  }
843
847
  }
@@ -848,7 +852,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
848
852
  // override runs on every compose the parent Container performs, so a
849
853
  // frame that never gets composed leaves the flags false and prevents a
850
854
  // spurious `resetDisplay()`.
851
- this.#placeholderShapePainted = this.#isPlaceholderShapeAtRender();
855
+ this.#firstResultViewportRepaintShapePainted = this.#needsFirstResultViewportRepaintAtRender();
852
856
  this.#partialResultShapePainted = this.#result !== undefined && this.#isPartial;
853
857
  return lines;
854
858
  }
@@ -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
  },
@@ -154,7 +154,6 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
154
154
  // deliberate human double-tap is always tens of milliseconds apart.
155
155
  const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
156
156
  const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
157
- const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
158
157
 
159
158
  export class InputController {
160
159
  constructor(
@@ -179,16 +178,6 @@ export class InputController {
179
178
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
180
179
  // #detectLeftDoubleTap.
181
180
  #leftTapCount = 0;
182
- // Streaming turns use a two-step Esc: first press arms this token, second press
183
- // within the window aborts the same live assistant turn. The token is a per-turn
184
- // sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
185
- // (see setupKeyHandlers), so it survives `message_start`/`message_update`
186
- // transitions inside a single turn but cannot leak across turn boundaries.
187
- #streamingEscapeTurnSentinel: object | undefined;
188
- #streamingEscapeArmedToken: object | undefined;
189
- #streamingEscapeArmedUntil = 0;
190
- #streamingEscapeTimer: NodeJS.Timeout | undefined;
191
- #streamingEscapeSessionSubscribed = false;
192
181
  // Sequential index for `local://attachment-N` references created by large-paste and
193
182
  // pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
194
183
  #attachmentCounter = 0;
@@ -238,50 +227,12 @@ export class InputController {
238
227
  const unsubscribe = tinyTitleClient.onProgress(update);
239
228
  }
240
229
 
241
- #clearStreamingEscapeArm(): void {
242
- this.#streamingEscapeArmedToken = undefined;
243
- this.#streamingEscapeArmedUntil = 0;
244
- if (this.#streamingEscapeTimer) {
245
- clearTimeout(this.#streamingEscapeTimer);
246
- this.#streamingEscapeTimer = undefined;
247
- }
248
- }
249
-
250
- #handleStreamingEscape(): void {
251
- if (!this.#streamingEscapeTurnSentinel) {
252
- this.#streamingEscapeTurnSentinel = {};
253
- }
254
- const token = this.#streamingEscapeTurnSentinel;
255
- const now = Date.now();
256
- if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
257
- this.#clearStreamingEscapeArm();
258
- void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
259
- return;
260
- }
261
-
262
- this.#clearStreamingEscapeArm();
263
- this.#streamingEscapeArmedToken = token;
264
- this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
265
- this.#streamingEscapeTimer = setTimeout(() => {
266
- if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
267
- this.#clearStreamingEscapeArm();
268
- }
269
- }, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
270
- this.#streamingEscapeTimer.unref?.();
271
- this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
230
+ #abortStreamingTurn(): void {
231
+ void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
272
232
  }
273
233
 
274
234
  setupKeyHandlers(): void {
275
235
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
276
- if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
277
- this.#streamingEscapeSessionSubscribed = true;
278
- this.ctx.session.subscribe(event => {
279
- if (event.type === "agent_start" || event.type === "agent_end") {
280
- this.#streamingEscapeTurnSentinel = undefined;
281
- this.#clearStreamingEscapeArm();
282
- }
283
- });
284
- }
285
236
  if (!this.#focusedLeftTapListenerInstalled) {
286
237
  this.#focusedLeftTapListenerInstalled = true;
287
238
  this.ctx.ui.addInputListener(data => {
@@ -351,7 +302,7 @@ export class InputController {
351
302
  if (this.ctx.loopModeEnabled) {
352
303
  this.ctx.pauseLoop();
353
304
  if (this.ctx.session.isStreaming) {
354
- this.#handleStreamingEscape();
305
+ this.#abortStreamingTurn();
355
306
  } else {
356
307
  this.ctx.cancelPendingSubmission();
357
308
  }
@@ -402,11 +353,10 @@ export class InputController {
402
353
  this.ctx.isPythonMode = false;
403
354
  this.ctx.updateEditorBorderColor();
404
355
  } else if (this.ctx.session.isStreaming) {
405
- this.#handleStreamingEscape();
356
+ this.#abortStreamingTurn();
406
357
  } else if (this.ctx.editor.getText().trim()) {
407
- // 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.
408
359
  this.ctx.lastEscapeTime = 0;
409
- this.#clearStreamingEscapeArm();
410
360
  } else if (vocalizer.isSpeaking()) {
411
361
  // TTS buffers seconds of PCM past the streaming abort, so an Esc
412
362
  // arriving after the model stopped would otherwise fall through to
@@ -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;
@@ -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
@@ -17,6 +17,7 @@ import type {
17
17
  RpcAvailableSlashCommand,
18
18
  RpcCommand,
19
19
  RpcExtensionUIRequest,
20
+ RpcExtensionUIResponse,
20
21
  RpcHandoffResult,
21
22
  RpcHostToolCallRequest,
22
23
  RpcHostToolCancelRequest,
@@ -722,25 +723,50 @@ export class RpcClient {
722
723
  /**
723
724
  * Trigger OAuth login for the given provider.
724
725
  * The server will emit an `open_url` extension_ui_request for the auth URL.
726
+ * Providers that require pasted-code completion may then emit an `input`
727
+ * extension_ui_request; pass `onManualCodeInput` to satisfy it.
725
728
  * Resolves when login completes or rejects on failure.
726
729
  *
727
730
  * @param onOpenUrl Called when the server emits the auth URL. The host must
728
- * open `url` in a browser for the callback-server OAuth flow to complete.
729
- * When the flow's callback server hosts a `/launch` redirect, `launchUrl`
730
- * is a short loopback URL that 302s to `url` — hosts SHOULD surface it as
731
- * the truncation-safe copy target so terminal viewport clipping cannot
732
- * corrupt trailing OAuth query parameters (e.g. `code_challenge_method=S256`).
731
+ * open `url` in a browser. When the flow's callback server hosts a
732
+ * `/launch` redirect, `launchUrl` is a short loopback URL that 302s to
733
+ * `url` — hosts SHOULD surface it as the truncation-safe copy target so
734
+ * terminal viewport clipping cannot corrupt trailing OAuth query
735
+ * parameters (e.g. `code_challenge_method=S256`).
733
736
  */
734
737
  async login(
735
738
  providerId: string,
736
- options?: { onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void },
739
+ options?: {
740
+ onOpenUrl?: (url: string, instructions?: string, launchUrl?: string) => void;
741
+ onManualCodeInput?: (prompt: { title: string; placeholder?: string }) => string | Promise<string>;
742
+ },
737
743
  ): Promise<{ providerId: string }> {
738
- const { onOpenUrl } = options ?? {};
739
- const listener = onOpenUrl
740
- ? (req: RpcExtensionUIRequest) => {
741
- if (req.method === "open_url") onOpenUrl(req.url, req.instructions, req.launchUrl);
742
- }
743
- : undefined;
744
+ const { onManualCodeInput, onOpenUrl } = options ?? {};
745
+ const listener =
746
+ onOpenUrl || onManualCodeInput
747
+ ? (req: RpcExtensionUIRequest) => {
748
+ if (req.method === "open_url") {
749
+ onOpenUrl?.(req.url, req.instructions, req.launchUrl);
750
+ return;
751
+ }
752
+ if (req.method !== "input" || !onManualCodeInput) return;
753
+ void Promise.resolve(onManualCodeInput({ title: req.title, placeholder: req.placeholder }))
754
+ .then(value => {
755
+ this.#writeFrame({
756
+ type: "extension_ui_response",
757
+ id: req.id,
758
+ value,
759
+ });
760
+ })
761
+ .catch(() => {
762
+ this.#writeFrame({
763
+ type: "extension_ui_response",
764
+ id: req.id,
765
+ cancelled: true,
766
+ });
767
+ });
768
+ }
769
+ : undefined;
744
770
  if (listener) this.#extensionUiListeners.add(listener);
745
771
  try {
746
772
  const response = await this.#send({ type: "login", providerId }, 600_000);
@@ -1006,7 +1032,10 @@ export class RpcClient {
1006
1032
  }
1007
1033
  }
1008
1034
 
1009
- #writeFrame(frame: RpcCommand | RpcHostToolResult | RpcHostToolUpdate, onError?: (error: Error) => void): void {
1035
+ #writeFrame(
1036
+ frame: RpcCommand | RpcExtensionUIResponse | RpcHostToolResult | RpcHostToolUpdate,
1037
+ onError?: (error: Error) => void,
1038
+ ): void {
1010
1039
  if (!this.#process?.stdin) {
1011
1040
  throw new Error("Client not started");
1012
1041
  }