gentle-pi 3.2.1 → 3.4.0

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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -5,8 +5,8 @@ import { statSync } from "node:fs";
5
5
  import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
6
6
  import * as os from "node:os";
7
7
  import { join } from "node:path";
8
- import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
9
- import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
8
+ import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
9
+ import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
10
10
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
11
11
  import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
12
12
  import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
@@ -14,10 +14,19 @@ import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/c
14
14
  import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
15
15
  import { agentsViewKey } from "../lib/agents-keys.ts";
16
16
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
17
- import { framePromptLines, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
18
- import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
17
+ import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
18
+ import { gentlePiConfigHome } from "../lib/agent-home.ts";
19
+ import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
20
+ import {
21
+ DOUBLE_ESC_CANCEL_WINDOW_MS,
22
+ resolveDoubleEscCancelPolicy,
23
+ writeDoubleEscCancelPolicy,
24
+ type DoubleEscCancelPolicy,
25
+ type DoubleEscCancelResolution,
26
+ } from "../lib/double-esc-cancel-policy.ts";
27
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
19
28
  import { UsageView } from "../lib/shell-usage-view.ts";
20
- import { sidebarPart } from "../lib/shell-sidebar.ts";
29
+ import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
21
30
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
22
31
  import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
23
32
  import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
@@ -181,34 +190,202 @@ interface PromptEditorDeps {
181
190
  bold: (text: string) => string;
182
191
  requestRender(): void;
183
192
  pending(): boolean;
193
+ now(): number;
194
+ /** Read fresh on every keypress: the command handler updates this in-memory, the editor never re-reads the file. */
195
+ doubleEscCancelEnabled(): boolean;
196
+ /** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
197
+ dispatchQueuedText(text: string): void;
184
198
  }
185
199
 
186
200
  const PROMPT_FRAME_ROLE = "border";
201
+ // Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
202
+ // this is the same muscle memory applied to clearing a non-empty draft.
203
+ const IDLE_ESC_CLEAR_WINDOW_MS = 500;
204
+
205
+ /**
206
+ * Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
207
+ * })`) rebuilds the editor text as
208
+ * `[queuedText, currentText].filter((t) => t.trim()).join("\n\n")`, where
209
+ * `currentText` is the draft captured just before the abort. Reverse that
210
+ * join to recover the queued text alone, so the draft can be restored by
211
+ * itself and the queued text dispatched as the next turn. `draft` is empty
212
+ * (including whitespace-only) whenever `.trim() === ""`, matching the
213
+ * `filter` predicate above exactly.
214
+ *
215
+ * Returns `""` only for the genuine no-queue case (`combined === draft`, or
216
+ * both empty). Returns `undefined` when `combined` does not match Pi's join
217
+ * shape at all — a future Pi change, or anything else that touched the
218
+ * editor during the abort. That distinction matters to the caller: an empty
219
+ * queue means "nothing to restore," while an unrecognized shape means "do
220
+ * not touch what Pi already wrote," so a mismatch is never silently treated
221
+ * as an empty queue.
222
+ */
223
+ export function extractQueuedText(combined: string, draft: string): string | undefined {
224
+ if (combined === draft) return "";
225
+ if (draft.trim() === "") return combined;
226
+ const suffix = `\n\n${draft}`;
227
+ return combined.endsWith(suffix) ? combined.slice(0, combined.length - suffix.length) : undefined;
228
+ }
187
229
 
188
230
  export class GentlePromptEditor extends CustomEditor {
189
231
  private promptState: PromptState = PROMPT_STATE.IDLE;
190
232
  private tick = 0;
233
+ private animationPolicy: AnimationPolicy = "quality";
191
234
  private pulse: NodeJS.Timeout | undefined;
192
235
  private readonly deps: PromptEditorDeps;
236
+ // CustomEditor keeps its own `keybindings` private, so this class holds
237
+ // its own reference to run the same app.interrupt match before deciding
238
+ // whether to swallow the keystroke.
239
+ private readonly keybindingsManager: KeybindingsManager;
240
+ private pendingEscapeCancelDeadline: number | undefined;
241
+ private pendingIdleClearDeadline: number | undefined;
242
+ // Snapshot of the draft at the first Esc; the second Esc only clears when
243
+ // the text is still exactly this, so an edit in between never gets
244
+ // silently discarded (issue #1218 review).
245
+ private pendingIdleClearText: string | undefined;
193
246
 
194
247
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
195
248
  super(tui, theme, keybindings);
196
249
  this.deps = deps;
250
+ this.keybindingsManager = keybindings;
251
+ }
252
+
253
+ setAnimationPolicy(policy: AnimationPolicy): void {
254
+ if (this.animationPolicy === policy) return;
255
+ this.animationPolicy = policy;
256
+ this.stopPulse();
257
+ if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
258
+ this.deps.requestRender();
259
+ }
260
+
261
+ private startPulse(): void {
262
+ if (this.animationPolicy === "potato") return;
263
+ this.pulse = setInterval(() => {
264
+ this.tick += 1;
265
+ this.deps.requestRender();
266
+ }, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
267
+ this.pulse.unref();
197
268
  }
198
269
 
199
270
  setWorking(working: boolean): void {
200
271
  this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
201
272
  this.stopPulse();
202
- if (working) {
203
- this.pulse = setInterval(() => {
204
- this.tick += 1;
205
- this.deps.requestRender();
206
- }, SHELL_PULSE_MS);
207
- this.pulse.unref();
273
+ if (!working) {
274
+ this.pendingEscapeCancelDeadline = undefined;
275
+ } else {
276
+ this.pendingIdleClearDeadline = undefined;
277
+ this.pendingIdleClearText = undefined;
278
+ this.startPulse();
208
279
  }
209
280
  this.deps.requestRender();
210
281
  }
211
282
 
283
+ /**
284
+ * Swallow the first Esc while working (issue #1163), opt-in via
285
+ * doubleEscCancelEnabled(). `pi.registerShortcut("escape")` is not viable
286
+ * here: Pi reserves app.interrupt and skips colliding extension
287
+ * shortcuts, so this has to sit in front of CustomEditor's own
288
+ * handleInput instead. The Esc that actually aborts the turn (the single
289
+ * Esc when double-esc-cancel is off, or the confirming second Esc when
290
+ * it is on) always goes through abortAndDispatchQueued so the queued
291
+ * text Pi would otherwise dump back into the editor is sent as the next
292
+ * turn instead (issue #1218). Idle double-Esc (tree/fork), bash-mode
293
+ * Esc, and autocomplete cancel are all decided by CustomEditor/onEscape
294
+ * and never reach this branch.
295
+ */
296
+ override handleInput(data: string): void {
297
+ // Any keystroke that is not the confirming Esc ends the pending idle
298
+ // clear, even one that leaves the text identical (type, then delete).
299
+ if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
300
+ this.pendingIdleClearDeadline = undefined;
301
+ this.pendingIdleClearText = undefined;
302
+ }
303
+ if (
304
+ this.promptState === PROMPT_STATE.WORKING &&
305
+ !this.isShowingAutocomplete() &&
306
+ this.keybindingsManager.matches(data, "app.interrupt")
307
+ ) {
308
+ if (!this.deps.doubleEscCancelEnabled()) {
309
+ this.abortAndDispatchQueued(data);
310
+ return;
311
+ }
312
+ if (this.isPendingEscapeCancel()) {
313
+ this.pendingEscapeCancelDeadline = undefined;
314
+ this.abortAndDispatchQueued(data);
315
+ return;
316
+ }
317
+ // Pi's own idle double-Esc (empty editor -> /tree or /fork) uses a
318
+ // 500ms window; canceling a running turn is a heavier, harder-to-undo
319
+ // action, so this confirmation deliberately gets double that time.
320
+ this.pendingEscapeCancelDeadline = this.deps.now() + DOUBLE_ESC_CANCEL_WINDOW_MS;
321
+ this.deps.requestRender();
322
+ return;
323
+ }
324
+ // Idle with a non-empty draft: Pi's own idle double-Esc only acts on an
325
+ // empty editor (tree/fork), so a draft's first Esc would otherwise do
326
+ // nothing. Mirror the same swallow-then-confirm shape as the
327
+ // working-cancel gate above, on the same 500ms window as Pi's own idle
328
+ // double-Esc (issue #1218). Bash-mode drafts ("!...", the same rule
329
+ // Pi's own interactive-mode uses to detect bash mode) are Pi's own
330
+ // bash-mode Esc territory and must never reach this gate.
331
+ if (
332
+ this.promptState === PROMPT_STATE.IDLE &&
333
+ !this.isShowingAutocomplete() &&
334
+ this.keybindingsManager.matches(data, "app.interrupt")
335
+ ) {
336
+ const text = this.getText();
337
+ if (text.trim() !== "" && !text.trimStart().startsWith("!")) {
338
+ // The second Esc only clears when the text is still exactly what
339
+ // it was at the first Esc; an edit in between starts a fresh
340
+ // first press on the new text instead of silently discarding it.
341
+ if (this.isPendingIdleClear() && this.pendingIdleClearText === text) {
342
+ this.pendingIdleClearDeadline = undefined;
343
+ this.pendingIdleClearText = undefined;
344
+ this.addToHistory(text);
345
+ this.setText("");
346
+ this.deps.requestRender();
347
+ return;
348
+ }
349
+ this.pendingIdleClearDeadline = this.deps.now() + IDLE_ESC_CLEAR_WINDOW_MS;
350
+ this.pendingIdleClearText = text;
351
+ this.deps.requestRender();
352
+ return;
353
+ }
354
+ }
355
+ super.handleInput(data);
356
+ }
357
+
358
+ /**
359
+ * Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
360
+ * synchronously by `super.handleInput`) restores `queuedText + draft`
361
+ * into the editor and aborts; snapshot the draft first, reconstruct the
362
+ * queued text from what comes back, restore the draft alone, and hand
363
+ * the queued text to the dispatcher so it is sent once the aborted run
364
+ * settles (see the `agent_settled` handler in `gentleShell`). Images
365
+ * inside queued messages are already dropped by Pi's own restore, before
366
+ * this code ever sees the text.
367
+ *
368
+ * `extractQueuedText` returning `undefined` means the restored text does
369
+ * not match Pi's own join shape; Pi's own text wins and is left exactly
370
+ * as it is, nothing is dispatched. An empty string means a genuine empty
371
+ * queue: there is nothing to restore, so `setText` is not called at all
372
+ * on the common no-queue path. Only a recognized, non-empty queue
373
+ * restores the draft and dispatches.
374
+ */
375
+ private abortAndDispatchQueued(data: string): void {
376
+ const draft = this.getText();
377
+ super.handleInput(data);
378
+ const queued = extractQueuedText(this.getText(), draft);
379
+ // undefined: unrecognized shape, Pi's own text stays untouched.
380
+ if (queued === undefined) return;
381
+ // "": nothing was queued, and the editor already holds the draft, so no
382
+ // redundant write. Anything else was recognized: the draft comes back
383
+ // alone, and only real text (not whitespace) is worth a turn.
384
+ if (queued !== "") this.setText(draft);
385
+ if (queued.trim() === "") return;
386
+ this.deps.dispatchQueuedText(queued);
387
+ }
388
+
212
389
  render(width: number): string[] {
213
390
  const lines = super.render(Math.max(1, width - 2));
214
391
  if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
@@ -221,6 +398,11 @@ export class GentlePromptEditor extends CustomEditor {
221
398
  borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
222
399
  fg: this.deps.fg,
223
400
  bold: this.deps.bold,
401
+ escHint: this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
402
+ ? DOUBLE_ESC_CANCEL_HINT
403
+ : this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
404
+ ? IDLE_ESC_CLEAR_HINT
405
+ : undefined,
224
406
  });
225
407
  }
226
408
 
@@ -228,6 +410,18 @@ export class GentlePromptEditor extends CustomEditor {
228
410
  this.stopPulse();
229
411
  }
230
412
 
413
+ private isPendingEscapeCancel(): boolean {
414
+ return this.pendingEscapeCancelDeadline !== undefined && this.deps.now() < this.pendingEscapeCancelDeadline;
415
+ }
416
+
417
+ private isPendingIdleClear(): boolean {
418
+ return (
419
+ this.pendingIdleClearDeadline !== undefined &&
420
+ this.deps.now() < this.pendingIdleClearDeadline &&
421
+ this.pendingIdleClearText === this.getText()
422
+ );
423
+ }
424
+
231
425
  private stopPulse(): void {
232
426
  if (this.pulse) clearInterval(this.pulse);
233
427
  this.pulse = undefined;
@@ -239,7 +433,11 @@ export class GentlePromptEditor extends CustomEditor {
239
433
  const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
240
434
  type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
241
435
 
242
- function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): boolean {
436
+ function installPrompt(
437
+ ctx: ExtensionContext,
438
+ onCreated: (prompt: GentlePromptEditor) => void,
439
+ promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
440
+ ): boolean {
243
441
  const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
244
442
  if (previous && !previous[PROMPT_OWNER]) return false;
245
443
  const factory: PromptFactory = (tui, theme, keybindings) => {
@@ -248,6 +446,9 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
248
446
  bold: (text) => ctx.ui.theme.bold(text),
249
447
  requestRender: () => tui.requestRender(),
250
448
  pending: () => ctx.hasPendingMessages(),
449
+ now: promptDeps.now,
450
+ doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
451
+ dispatchQueuedText: promptDeps.dispatchQueuedText,
251
452
  });
252
453
  onCreated(prompt);
253
454
  return prompt;
@@ -257,6 +458,45 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
257
458
  return true;
258
459
  }
259
460
 
461
+ const DOUBLE_ESC_CANCEL_COMMAND_NAME = "gentle:double-esc-cancel";
462
+
463
+ function describeDoubleEscCancelSource(resolution: DoubleEscCancelResolution): string {
464
+ switch (resolution.source) {
465
+ case "global_file":
466
+ return `global file ${resolution.globalFile}`;
467
+ case "environment":
468
+ return "GENTLE_PI_DOUBLE_ESC_CANCEL";
469
+ default:
470
+ return "built-in default";
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Report the effective policy, the source that decided it, and (when this
476
+ * invocation just wrote one) the policy it wrote. Unlike background-subagents
477
+ * there is no project-file layer to outrank the write, so a write always
478
+ * takes effect immediately.
479
+ */
480
+ function renderDoubleEscCancelReport(
481
+ resolution: DoubleEscCancelResolution,
482
+ wrote?: DoubleEscCancelPolicy,
483
+ ): { message: string; type: "info" | "warning" } {
484
+ const lines = [`double-esc-cancel: ${resolution.policy} (decided by ${describeDoubleEscCancelSource(resolution)})`];
485
+ if (wrote !== undefined) lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
486
+ if (resolution.malformed) {
487
+ lines.push(`${resolution.globalFile} is present but malformed, so the policy fails closed to off and the environment variable is not consulted.`);
488
+ }
489
+ if (resolution.envValue !== undefined && resolution.source !== "environment") {
490
+ lines.push(
491
+ resolution.envValue === "on" || resolution.envValue === "off"
492
+ ? `GENTLE_PI_DOUBLE_ESC_CANCEL=${resolution.envValue} is set, but the global file exists and decides; the env var applies only when no file exists.`
493
+ : `GENTLE_PI_DOUBLE_ESC_CANCEL="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
494
+ );
495
+ }
496
+ lines.push("Resolution order (first hit wins): global file, GENTLE_PI_DOUBLE_ESC_CANCEL, built-in default off.");
497
+ return { message: lines.join("\n"), type: resolution.malformed ? "warning" : "info" };
498
+ }
499
+
260
500
  const CHANGES_WIDGET_KEY = "gentle-shell-changes";
261
501
  const CHANGES_COMMAND_NAME = "gentle:changes";
262
502
  const CHANGES_SHORTCUT_DEFAULT = "alt+g";
@@ -318,6 +558,12 @@ export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string |
318
558
  return value === "" || value.toLowerCase() === "off" ? undefined : value;
319
559
  }
320
560
 
561
+ export function usageShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
562
+ const value = env.GENTLE_PI_SHELL_USAGE_KEY?.trim();
563
+ if (value === undefined) return USAGE_SHORTCUT_DEFAULT;
564
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
565
+ }
566
+
321
567
  function positiveMs(value: string | undefined, fallback: number): number {
322
568
  const parsed = Number.parseInt(value ?? "", 10);
323
569
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -337,15 +583,24 @@ interface OverlayDeps {
337
583
  refresh(): Promise<ChangesModel>;
338
584
  apply(ctx: ExtensionContext, model: ChangesModel): void;
339
585
  pollMs: number;
586
+ gitForRoot(root: string): GitRunner;
340
587
  }
341
588
 
342
- // Refresh only the captured session model. Never read live files or Git here.
589
+ // Refresh only the captured session model. Never read live files here; the
590
+ // only Git the overlay touches is each root's HEAD, to label its tree.
343
591
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
344
592
  let host: ExternalEditorHost | undefined;
345
593
  let view: WorktreeChangesView | undefined;
594
+ // Session evidence knows roots, not branches; label them while the overlay
595
+ // is open and repaint when Git answers.
596
+ const labels = new RootBranchLabels(deps.gitForRoot, () => {
597
+ view?.update(labels.decorate(deps.worktrees()));
598
+ host?.requestRender();
599
+ });
600
+ const worktrees = () => labels.decorate(deps.worktrees());
346
601
  const refresh = async () => {
347
602
  const latest = await deps.refresh();
348
- view?.update(deps.worktrees());
603
+ view?.update(worktrees());
349
604
  deps.apply(ctx, latest);
350
605
  };
351
606
  const poll = setInterval(() => void refresh(), deps.pollMs);
@@ -354,7 +609,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
354
609
  const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
355
610
  (tui, theme, _keybindings, done) => {
356
611
  host = tui;
357
- view = new WorktreeChangesView(deps.worktrees(), {
612
+ view = new WorktreeChangesView(worktrees(), {
358
613
  theme,
359
614
  rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
360
615
  loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
@@ -417,6 +672,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
417
672
  }
418
673
 
419
674
  const USAGE_COMMAND_NAME = "gentle:usage";
675
+ const USAGE_SHORTCUT_DEFAULT = "alt+u";
420
676
  const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
421
677
  const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
422
678
  const SHA_PREFIX_LENGTH = 16;
@@ -502,28 +758,67 @@ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof
502
758
  }
503
759
  }
504
760
 
761
+ // A registered source is foreign code running inside a fire-and-forget
762
+ // refresh: it must degrade exactly like the built-in fetchers above, never
763
+ // throw past this call, and never leave an unhandled rejection behind.
764
+ async function fetchFromSource(source: UsageSource, apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
765
+ try {
766
+ const result = await source.fetch(apiKey, fetchFn, now);
767
+ return result === undefined ? undefined : parseProviderUsage(result, source.provider);
768
+ } catch {
769
+ return undefined;
770
+ }
771
+ }
772
+
505
773
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
506
774
  installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
507
775
  if (!shellEnabled(env)) return;
508
776
  const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
509
777
  const usage = new UsageStore();
778
+ // Providers gentle-shell has never heard of get a usage source too, when
779
+ // the extension that owns them registers one on pi.events; see the
780
+ // USAGE_SOURCE_EVENT subscription below.
781
+ const usageSources = new UsageSourceRegistry();
510
782
  let renderHost: ShellRenderHost | undefined;
511
783
  // The 5-minute rule is per provider: one provider's fetch cannot leave the
512
784
  // next one waiting for an interval it never used.
513
785
  const usageFetchedAt = new Map<string, number>();
514
786
  const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
515
787
  const provider = ctx.model?.provider;
516
- if (provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
788
+ if (!provider) return;
789
+ const source = usageSources.get(provider);
790
+ if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
517
791
  const now = deps.now();
518
792
  if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
519
793
  usageFetchedAt.set(provider, now);
520
794
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
521
- const fetched = provider === NAN_PROVIDER ? await fetchNanUsage(apiKey, deps.fetch, deps.now()) : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
795
+ const fetched = source
796
+ ? await fetchFromSource(source, apiKey, deps.fetch, deps.now())
797
+ : provider === NAN_PROVIDER
798
+ ? await fetchNanUsage(apiKey, deps.fetch, deps.now())
799
+ : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
522
800
  if (!fetched) return;
801
+ // A registered source can be replaced while its own fetch is still in
802
+ // flight; the identity captured above is this call's source, so a stale
803
+ // answer that outlives its replacement is discarded instead of
804
+ // overwriting whatever the replacement already recorded.
805
+ if (source && usageSources.get(provider) !== source) return;
523
806
  usage.record(fetched);
524
807
  renderHost?.invalidateSidebar?.();
525
808
  renderHost?.requestRender();
526
809
  };
810
+ // Subscribed once, for the life of the extension: a registration can
811
+ // arrive before the first session_start (the owning extension's factory
812
+ // runs first) or after it (its own session_start fires later, or it
813
+ // registers lazily). Either order is fine: a registration for the
814
+ // currently active provider forces exactly one refresh, so the panel
815
+ // never waits for the 5-minute window or the next turn to notice it.
816
+ pi.events.on(USAGE_SOURCE_EVENT, (payload) => {
817
+ const source = parseUsageSource(payload);
818
+ if (!source) return;
819
+ usageSources.register(source);
820
+ if (currentContext?.model?.provider === source.provider) void refreshUsage(currentContext, true);
821
+ });
527
822
  pi.on("after_provider_response", (event) => {
528
823
  const parsed = parseUsageHeaders(event.headers, deps.now());
529
824
  if (!parsed) return;
@@ -536,25 +831,54 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
536
831
  const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
537
832
  return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
538
833
  });
834
+ const openUsage = async (ctx: ExtensionContext) => {
835
+ await refreshUsage(ctx, true);
836
+ await ctx.ui.custom<null>(
837
+ (tui, theme, _keybindings, done) =>
838
+ new UsageView(usage, {
839
+ theme,
840
+ now: () => deps.now(),
841
+ active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
842
+ registry: () => usageSources,
843
+ onRefresh: () => refreshUsage(ctx, true),
844
+ onClose: () => done(null),
845
+ requestRender: () => tui.requestRender(),
846
+ }),
847
+ { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
848
+ );
849
+ };
539
850
  pi.registerCommand(USAGE_COMMAND_NAME, {
540
851
  description: "Show subscription usage windows for the connected providers. Press r to refetch.",
541
- handler: async (_args, ctx) => {
542
- await refreshUsage(ctx, true);
543
- await ctx.ui.custom<null>(
544
- (tui, theme, _keybindings, done) =>
545
- new UsageView(usage, {
546
- theme,
547
- now: () => deps.now(),
548
- active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
549
- onRefresh: () => refreshUsage(ctx, true),
550
- onClose: () => done(null),
551
- requestRender: () => tui.requestRender(),
552
- }),
553
- { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
554
- );
555
- },
852
+ handler: async (_args, ctx) => openUsage(ctx),
556
853
  });
854
+ const usageShortcutKey = usageShortcut(env);
855
+ if (usageShortcutKey) {
856
+ pi.registerShortcut(usageShortcutKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
857
+ description: "Show subscription usage windows for the connected providers",
858
+ handler: async (ctx) => openUsage(ctx),
859
+ });
860
+ }
557
861
  let prompt: GentlePromptEditor | undefined;
862
+ // Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
863
+ // a turn with a non-empty queue; sent exactly once, from agent_settled,
864
+ // once the aborted run has fully settled (issue #1218). Several aborts
865
+ // before that settle append in order, joined the way Pi joins its own
866
+ // queue, so nothing is overwritten. It belongs to the current session and
867
+ // is dropped on session_shutdown.
868
+ let pendingQueuedText: string | undefined;
869
+ // Resolved once at startup and cached in memory so the editor never
870
+ // re-reads the file per keypress. The /gentle:double-esc-cancel command
871
+ // below is the only place that touches the file, and every invocation
872
+ // re-syncs this cache from disk first, so status, the no-argument toggle
873
+ // direction, and the Esc gate always describe the same effective policy
874
+ // even when another session or a hand edit changed the file mid-session.
875
+ const doubleEscCancelConfigHome = gentlePiConfigHome(env);
876
+ const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
877
+ let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
878
+ let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
879
+ env,
880
+ gentlePiConfigHome: doubleEscCancelConfigHome,
881
+ }).policy;
558
882
  let changes: SessionChanges | undefined;
559
883
  let registry: SessionWorktreeRegistry | undefined;
560
884
  let currentContext: ExtensionContext | undefined;
@@ -616,14 +940,36 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
616
940
  render: (width) => renderShellSidebarBar(footerModel(), theme, width),
617
941
  invalidate() {},
618
942
  });
943
+ // The header row carries everything that ticks every frame (model,
944
+ // effort, context, cost, usage) plus session identity; it never sees
945
+ // extension statuses or the working/thinking state.
946
+ const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
947
+ const disposeHeader = sidebarHeader(tui, {
948
+ digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
949
+ render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
950
+ invalidate() {},
951
+ handleMouse(event) {
952
+ if (event.type !== "click" || event.button !== "left") return undefined;
953
+ if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
954
+ const { usageSpan } = headerBar(event.width);
955
+ if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
956
+ void openUsage(ctx);
957
+ return { handled: true, render: true };
958
+ },
959
+ });
619
960
  const uninstall = installSidebar(tui, theme);
620
- return { ...part, dispose() { uninstall(); part.dispose(); } };
961
+ return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
621
962
  });
622
963
  void refreshUsage(ctx, true);
623
- const ownsPrompt = installPrompt(ctx, (created) => {
624
- prompt?.dispose();
625
- prompt = created;
626
- });
964
+ const ownsPrompt = installPrompt(
965
+ ctx,
966
+ (created) => {
967
+ prompt?.dispose();
968
+ prompt = created;
969
+ prompt.setAnimationPolicy(animationPolicy);
970
+ },
971
+ { now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
972
+ );
627
973
  // Hide native feedback only when our petal replaces it. Native transcript
628
974
  // thinking blocks remain Pi-owned; this changes only the supported loader UI.
629
975
  if (ownsPrompt) ctx.ui.setWorkingVisible(false);
@@ -639,6 +985,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
639
985
  applyChanges(ctx, tracker.model);
640
986
  });
641
987
  pi.on("session_shutdown", (_event, ctx) => {
988
+ pendingQueuedText = undefined;
642
989
  prompt?.dispose();
643
990
  prompt = undefined;
644
991
  if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
@@ -659,7 +1006,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
659
1006
  ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
660
1007
  return;
661
1008
  }
662
- await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
1009
+ await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env), gitForRoot: (root) => deps.gitRunner(root) });
663
1010
  };
664
1011
  pi.registerCommand(CHANGES_COMMAND_NAME, {
665
1012
  description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
@@ -683,13 +1030,103 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
683
1030
  handler: async (ctx) => showCommandPalette(pi, ctx, env),
684
1031
  });
685
1032
  }
1033
+ pi.registerCommand("gentle:animations", {
1034
+ description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
1035
+ // No argument opens a selectable menu when an interactive UI is present;
1036
+ // headless callers and fakes without ui.select keep the status fallback.
1037
+ handler: async (args, ctx) => {
1038
+ let action = args.trim() || "status";
1039
+ if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
1040
+ const selected = await ctx.ui.select(
1041
+ `Gentle animations (current: ${animationPolicy})`,
1042
+ ["quality", "performance", "potato", "status"],
1043
+ );
1044
+ if (selected === undefined) return;
1045
+ action = selected;
1046
+ }
1047
+ if (action !== "status" && action !== "quality" && action !== "performance" && action !== "potato") {
1048
+ ctx.ui.notify("Use /gentle:animations status|quality|performance|potato.", "warning");
1049
+ return;
1050
+ }
1051
+ try {
1052
+ if (action !== "status") writeAnimationPolicy(action, animationOptions);
1053
+ const result = resolveAnimationPolicy(animationOptions);
1054
+ animationPolicy = result.policy;
1055
+ prompt?.setAnimationPolicy(animationPolicy);
1056
+ const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
1057
+ ctx.ui.notify(`animations: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to quality" : ""}. Prompt applies now; startup banner applies at next creation.`, result.malformed ? "warning" : "info");
1058
+ } catch (error) {
1059
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1060
+ }
1061
+ },
1062
+ });
1063
+ // User-owned, like gentle:background-subagents and gentle:review-mode: the
1064
+ // only writer is this handler, reached only by explicit invocation. Unlike
1065
+ // those two, no argument toggles the effective policy instead of merely
1066
+ // reporting it (see odd/tasks/double-esc-cancel.md).
1067
+ pi.registerCommand(DOUBLE_ESC_CANCEL_COMMAND_NAME, {
1068
+ description: "Show or set the double-esc-cancel preference (status|enable|disable); no argument toggles it. User-initiated only.",
1069
+ handler: async (args, ctx) => {
1070
+ const trimmed = args.trim();
1071
+ if (trimmed !== "" && trimmed !== "status" && trimmed !== "enable" && trimmed !== "disable") {
1072
+ ctx.ui.notify(`Unknown /${DOUBLE_ESC_CANCEL_COMMAND_NAME} sub-action "${trimmed}". Use status, enable, or disable.`, "warning");
1073
+ return;
1074
+ }
1075
+ try {
1076
+ const before = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
1077
+ doubleEscCancelPolicy = before.policy;
1078
+ const subAction = trimmed === "" ? (before.policy === "on" ? "disable" : "enable") : trimmed;
1079
+ if (subAction === "status") {
1080
+ const report = renderDoubleEscCancelReport(before);
1081
+ ctx.ui.notify(report.message, report.type);
1082
+ return;
1083
+ }
1084
+ const wrote: DoubleEscCancelPolicy = subAction === "enable" ? "on" : "off";
1085
+ writeDoubleEscCancelPolicy(wrote, { gentlePiConfigHome: doubleEscCancelConfigHome });
1086
+ const after = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
1087
+ // Cache what the file actually resolves to, not what was written: a
1088
+ // competing writer or a read failure would otherwise leave the gate
1089
+ // and the report disagreeing.
1090
+ doubleEscCancelPolicy = after.policy;
1091
+ const report = renderDoubleEscCancelReport(after, wrote);
1092
+ ctx.ui.notify(report.message, report.type);
1093
+ } catch (error) {
1094
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1095
+ }
1096
+ },
1097
+ });
686
1098
  pi.on("agent_start", (_event, ctx) => {
1099
+ // A turn can start any other way (the user sending the draft, an
1100
+ // extension, a shortcut) before the aborted run's own agent_settled
1101
+ // below has delivered the pending text. Nothing is sent from here: Pi
1102
+ // is mid-turn, so the text simply waits and goes out, once, when that
1103
+ // turn settles. It is never dropped.
687
1104
  prompt?.setWorking(true);
688
1105
  // The dev-binary card is a startup notice: it leaves with the first prompt.
689
1106
  if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
690
1107
  });
691
- pi.on("agent_settled", () => {
1108
+ pi.on("agent_settled", (_event, ctx) => {
1109
+ // Pi clears its own run-active flag before emitting agent_settled, so
1110
+ // this is normally idle; if a run is somehow still in flight the prompt
1111
+ // stays working and the pending text waits for the next settle.
1112
+ if (!ctx.isIdle()) return;
692
1113
  prompt?.setWorking(false);
1114
+ if (pendingQueuedText === undefined) return;
1115
+ const queued = pendingQueuedText;
1116
+ pendingQueuedText = undefined;
1117
+ try {
1118
+ pi.sendUserMessage(queued);
1119
+ } catch (error) {
1120
+ // Never drop the user's words: put them back in front of the draft,
1121
+ // exactly the shape Pi's own restore would have left, and say why.
1122
+ if (prompt) {
1123
+ const current = prompt.getText();
1124
+ prompt.setText([queued, current].filter((text) => text.trim() !== "").join("\n\n"));
1125
+ } else {
1126
+ pendingQueuedText = queued;
1127
+ }
1128
+ if (ctx.hasUI) ctx.ui.notify(`Could not send the queued message after cancel; it is back in the editor: ${error instanceof Error ? error.message : String(error)}`, "error");
1129
+ }
693
1130
  });
694
1131
  pi.on("agent_end", async (_event, ctx) => {
695
1132
  await refreshChanges(ctx);