gentle-pi 3.2.0 → 3.3.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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -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, 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,18 @@ 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, parseCodexUsage, 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 {
20
+ DOUBLE_ESC_CANCEL_WINDOW_MS,
21
+ resolveDoubleEscCancelPolicy,
22
+ writeDoubleEscCancelPolicy,
23
+ type DoubleEscCancelPolicy,
24
+ type DoubleEscCancelResolution,
25
+ } from "../lib/double-esc-cancel-policy.ts";
26
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
19
27
  import { UsageView } from "../lib/shell-usage-view.ts";
20
- import { sidebarPart } from "../lib/shell-sidebar.ts";
28
+ import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
21
29
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
22
30
  import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
23
31
  import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
@@ -181,25 +189,73 @@ interface PromptEditorDeps {
181
189
  bold: (text: string) => string;
182
190
  requestRender(): void;
183
191
  pending(): boolean;
192
+ now(): number;
193
+ /** Read fresh on every keypress: the command handler updates this in-memory, the editor never re-reads the file. */
194
+ doubleEscCancelEnabled(): boolean;
195
+ /** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
196
+ dispatchQueuedText(text: string): void;
184
197
  }
185
198
 
186
199
  const PROMPT_FRAME_ROLE = "border";
200
+ // Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
201
+ // this is the same muscle memory applied to clearing a non-empty draft.
202
+ const IDLE_ESC_CLEAR_WINDOW_MS = 500;
203
+
204
+ /**
205
+ * Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
206
+ * })`) rebuilds the editor text as
207
+ * `[queuedText, currentText].filter((t) => t.trim()).join("\n\n")`, where
208
+ * `currentText` is the draft captured just before the abort. Reverse that
209
+ * join to recover the queued text alone, so the draft can be restored by
210
+ * itself and the queued text dispatched as the next turn. `draft` is empty
211
+ * (including whitespace-only) whenever `.trim() === ""`, matching the
212
+ * `filter` predicate above exactly.
213
+ *
214
+ * Returns `""` only for the genuine no-queue case (`combined === draft`, or
215
+ * both empty). Returns `undefined` when `combined` does not match Pi's join
216
+ * shape at all — a future Pi change, or anything else that touched the
217
+ * editor during the abort. That distinction matters to the caller: an empty
218
+ * queue means "nothing to restore," while an unrecognized shape means "do
219
+ * not touch what Pi already wrote," so a mismatch is never silently treated
220
+ * as an empty queue.
221
+ */
222
+ export function extractQueuedText(combined: string, draft: string): string | undefined {
223
+ if (combined === draft) return "";
224
+ if (draft.trim() === "") return combined;
225
+ const suffix = `\n\n${draft}`;
226
+ return combined.endsWith(suffix) ? combined.slice(0, combined.length - suffix.length) : undefined;
227
+ }
187
228
 
188
229
  export class GentlePromptEditor extends CustomEditor {
189
230
  private promptState: PromptState = PROMPT_STATE.IDLE;
190
231
  private tick = 0;
191
232
  private pulse: NodeJS.Timeout | undefined;
192
233
  private readonly deps: PromptEditorDeps;
234
+ // CustomEditor keeps its own `keybindings` private, so this class holds
235
+ // its own reference to run the same app.interrupt match before deciding
236
+ // whether to swallow the keystroke.
237
+ private readonly keybindingsManager: KeybindingsManager;
238
+ private pendingEscapeCancelDeadline: number | undefined;
239
+ private pendingIdleClearDeadline: number | undefined;
240
+ // Snapshot of the draft at the first Esc; the second Esc only clears when
241
+ // the text is still exactly this, so an edit in between never gets
242
+ // silently discarded (issue #1218 review).
243
+ private pendingIdleClearText: string | undefined;
193
244
 
194
245
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
195
246
  super(tui, theme, keybindings);
196
247
  this.deps = deps;
248
+ this.keybindingsManager = keybindings;
197
249
  }
198
250
 
199
251
  setWorking(working: boolean): void {
200
252
  this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
201
253
  this.stopPulse();
202
- if (working) {
254
+ if (!working) {
255
+ this.pendingEscapeCancelDeadline = undefined;
256
+ } else {
257
+ this.pendingIdleClearDeadline = undefined;
258
+ this.pendingIdleClearText = undefined;
203
259
  this.pulse = setInterval(() => {
204
260
  this.tick += 1;
205
261
  this.deps.requestRender();
@@ -209,6 +265,112 @@ export class GentlePromptEditor extends CustomEditor {
209
265
  this.deps.requestRender();
210
266
  }
211
267
 
268
+ /**
269
+ * Swallow the first Esc while working (issue #1163), opt-in via
270
+ * doubleEscCancelEnabled(). `pi.registerShortcut("escape")` is not viable
271
+ * here: Pi reserves app.interrupt and skips colliding extension
272
+ * shortcuts, so this has to sit in front of CustomEditor's own
273
+ * handleInput instead. The Esc that actually aborts the turn (the single
274
+ * Esc when double-esc-cancel is off, or the confirming second Esc when
275
+ * it is on) always goes through abortAndDispatchQueued so the queued
276
+ * text Pi would otherwise dump back into the editor is sent as the next
277
+ * turn instead (issue #1218). Idle double-Esc (tree/fork), bash-mode
278
+ * Esc, and autocomplete cancel are all decided by CustomEditor/onEscape
279
+ * and never reach this branch.
280
+ */
281
+ override handleInput(data: string): void {
282
+ // Any keystroke that is not the confirming Esc ends the pending idle
283
+ // clear, even one that leaves the text identical (type, then delete).
284
+ if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
285
+ this.pendingIdleClearDeadline = undefined;
286
+ this.pendingIdleClearText = undefined;
287
+ }
288
+ if (
289
+ this.promptState === PROMPT_STATE.WORKING &&
290
+ !this.isShowingAutocomplete() &&
291
+ this.keybindingsManager.matches(data, "app.interrupt")
292
+ ) {
293
+ if (!this.deps.doubleEscCancelEnabled()) {
294
+ this.abortAndDispatchQueued(data);
295
+ return;
296
+ }
297
+ if (this.isPendingEscapeCancel()) {
298
+ this.pendingEscapeCancelDeadline = undefined;
299
+ this.abortAndDispatchQueued(data);
300
+ return;
301
+ }
302
+ // Pi's own idle double-Esc (empty editor -> /tree or /fork) uses a
303
+ // 500ms window; canceling a running turn is a heavier, harder-to-undo
304
+ // action, so this confirmation deliberately gets double that time.
305
+ this.pendingEscapeCancelDeadline = this.deps.now() + DOUBLE_ESC_CANCEL_WINDOW_MS;
306
+ this.deps.requestRender();
307
+ return;
308
+ }
309
+ // Idle with a non-empty draft: Pi's own idle double-Esc only acts on an
310
+ // empty editor (tree/fork), so a draft's first Esc would otherwise do
311
+ // nothing. Mirror the same swallow-then-confirm shape as the
312
+ // working-cancel gate above, on the same 500ms window as Pi's own idle
313
+ // double-Esc (issue #1218). Bash-mode drafts ("!...", the same rule
314
+ // Pi's own interactive-mode uses to detect bash mode) are Pi's own
315
+ // bash-mode Esc territory and must never reach this gate.
316
+ if (
317
+ this.promptState === PROMPT_STATE.IDLE &&
318
+ !this.isShowingAutocomplete() &&
319
+ this.keybindingsManager.matches(data, "app.interrupt")
320
+ ) {
321
+ const text = this.getText();
322
+ if (text.trim() !== "" && !text.trimStart().startsWith("!")) {
323
+ // The second Esc only clears when the text is still exactly what
324
+ // it was at the first Esc; an edit in between starts a fresh
325
+ // first press on the new text instead of silently discarding it.
326
+ if (this.isPendingIdleClear() && this.pendingIdleClearText === text) {
327
+ this.pendingIdleClearDeadline = undefined;
328
+ this.pendingIdleClearText = undefined;
329
+ this.addToHistory(text);
330
+ this.setText("");
331
+ this.deps.requestRender();
332
+ return;
333
+ }
334
+ this.pendingIdleClearDeadline = this.deps.now() + IDLE_ESC_CLEAR_WINDOW_MS;
335
+ this.pendingIdleClearText = text;
336
+ this.deps.requestRender();
337
+ return;
338
+ }
339
+ }
340
+ super.handleInput(data);
341
+ }
342
+
343
+ /**
344
+ * Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
345
+ * synchronously by `super.handleInput`) restores `queuedText + draft`
346
+ * into the editor and aborts; snapshot the draft first, reconstruct the
347
+ * queued text from what comes back, restore the draft alone, and hand
348
+ * the queued text to the dispatcher so it is sent once the aborted run
349
+ * settles (see the `agent_settled` handler in `gentleShell`). Images
350
+ * inside queued messages are already dropped by Pi's own restore, before
351
+ * this code ever sees the text.
352
+ *
353
+ * `extractQueuedText` returning `undefined` means the restored text does
354
+ * not match Pi's own join shape; Pi's own text wins and is left exactly
355
+ * as it is, nothing is dispatched. An empty string means a genuine empty
356
+ * queue: there is nothing to restore, so `setText` is not called at all
357
+ * on the common no-queue path. Only a recognized, non-empty queue
358
+ * restores the draft and dispatches.
359
+ */
360
+ private abortAndDispatchQueued(data: string): void {
361
+ const draft = this.getText();
362
+ super.handleInput(data);
363
+ const queued = extractQueuedText(this.getText(), draft);
364
+ // undefined: unrecognized shape, Pi's own text stays untouched.
365
+ if (queued === undefined) return;
366
+ // "": nothing was queued, and the editor already holds the draft, so no
367
+ // redundant write. Anything else was recognized: the draft comes back
368
+ // alone, and only real text (not whitespace) is worth a turn.
369
+ if (queued !== "") this.setText(draft);
370
+ if (queued.trim() === "") return;
371
+ this.deps.dispatchQueuedText(queued);
372
+ }
373
+
212
374
  render(width: number): string[] {
213
375
  const lines = super.render(Math.max(1, width - 2));
214
376
  if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
@@ -221,6 +383,11 @@ export class GentlePromptEditor extends CustomEditor {
221
383
  borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
222
384
  fg: this.deps.fg,
223
385
  bold: this.deps.bold,
386
+ escHint: this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
387
+ ? DOUBLE_ESC_CANCEL_HINT
388
+ : this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
389
+ ? IDLE_ESC_CLEAR_HINT
390
+ : undefined,
224
391
  });
225
392
  }
226
393
 
@@ -228,6 +395,18 @@ export class GentlePromptEditor extends CustomEditor {
228
395
  this.stopPulse();
229
396
  }
230
397
 
398
+ private isPendingEscapeCancel(): boolean {
399
+ return this.pendingEscapeCancelDeadline !== undefined && this.deps.now() < this.pendingEscapeCancelDeadline;
400
+ }
401
+
402
+ private isPendingIdleClear(): boolean {
403
+ return (
404
+ this.pendingIdleClearDeadline !== undefined &&
405
+ this.deps.now() < this.pendingIdleClearDeadline &&
406
+ this.pendingIdleClearText === this.getText()
407
+ );
408
+ }
409
+
231
410
  private stopPulse(): void {
232
411
  if (this.pulse) clearInterval(this.pulse);
233
412
  this.pulse = undefined;
@@ -239,7 +418,11 @@ export class GentlePromptEditor extends CustomEditor {
239
418
  const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
240
419
  type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
241
420
 
242
- function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): boolean {
421
+ function installPrompt(
422
+ ctx: ExtensionContext,
423
+ onCreated: (prompt: GentlePromptEditor) => void,
424
+ promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
425
+ ): boolean {
243
426
  const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
244
427
  if (previous && !previous[PROMPT_OWNER]) return false;
245
428
  const factory: PromptFactory = (tui, theme, keybindings) => {
@@ -248,6 +431,9 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
248
431
  bold: (text) => ctx.ui.theme.bold(text),
249
432
  requestRender: () => tui.requestRender(),
250
433
  pending: () => ctx.hasPendingMessages(),
434
+ now: promptDeps.now,
435
+ doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
436
+ dispatchQueuedText: promptDeps.dispatchQueuedText,
251
437
  });
252
438
  onCreated(prompt);
253
439
  return prompt;
@@ -257,6 +443,45 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
257
443
  return true;
258
444
  }
259
445
 
446
+ const DOUBLE_ESC_CANCEL_COMMAND_NAME = "gentle:double-esc-cancel";
447
+
448
+ function describeDoubleEscCancelSource(resolution: DoubleEscCancelResolution): string {
449
+ switch (resolution.source) {
450
+ case "global_file":
451
+ return `global file ${resolution.globalFile}`;
452
+ case "environment":
453
+ return "GENTLE_PI_DOUBLE_ESC_CANCEL";
454
+ default:
455
+ return "built-in default";
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Report the effective policy, the source that decided it, and (when this
461
+ * invocation just wrote one) the policy it wrote. Unlike background-subagents
462
+ * there is no project-file layer to outrank the write, so a write always
463
+ * takes effect immediately.
464
+ */
465
+ function renderDoubleEscCancelReport(
466
+ resolution: DoubleEscCancelResolution,
467
+ wrote?: DoubleEscCancelPolicy,
468
+ ): { message: string; type: "info" | "warning" } {
469
+ const lines = [`double-esc-cancel: ${resolution.policy} (decided by ${describeDoubleEscCancelSource(resolution)})`];
470
+ if (wrote !== undefined) lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
471
+ if (resolution.malformed) {
472
+ lines.push(`${resolution.globalFile} is present but malformed, so the policy fails closed to off and the environment variable is not consulted.`);
473
+ }
474
+ if (resolution.envValue !== undefined && resolution.source !== "environment") {
475
+ lines.push(
476
+ resolution.envValue === "on" || resolution.envValue === "off"
477
+ ? `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.`
478
+ : `GENTLE_PI_DOUBLE_ESC_CANCEL="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
479
+ );
480
+ }
481
+ lines.push("Resolution order (first hit wins): global file, GENTLE_PI_DOUBLE_ESC_CANCEL, built-in default off.");
482
+ return { message: lines.join("\n"), type: resolution.malformed ? "warning" : "info" };
483
+ }
484
+
260
485
  const CHANGES_WIDGET_KEY = "gentle-shell-changes";
261
486
  const CHANGES_COMMAND_NAME = "gentle:changes";
262
487
  const CHANGES_SHORTCUT_DEFAULT = "alt+g";
@@ -318,6 +543,12 @@ export function changesShortcut(env: NodeJS.ProcessEnv = process.env): string |
318
543
  return value === "" || value.toLowerCase() === "off" ? undefined : value;
319
544
  }
320
545
 
546
+ export function usageShortcut(env: NodeJS.ProcessEnv = process.env): string | undefined {
547
+ const value = env.GENTLE_PI_SHELL_USAGE_KEY?.trim();
548
+ if (value === undefined) return USAGE_SHORTCUT_DEFAULT;
549
+ return value === "" || value.toLowerCase() === "off" ? undefined : value;
550
+ }
551
+
321
552
  function positiveMs(value: string | undefined, fallback: number): number {
322
553
  const parsed = Number.parseInt(value ?? "", 10);
323
554
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
@@ -337,15 +568,24 @@ interface OverlayDeps {
337
568
  refresh(): Promise<ChangesModel>;
338
569
  apply(ctx: ExtensionContext, model: ChangesModel): void;
339
570
  pollMs: number;
571
+ gitForRoot(root: string): GitRunner;
340
572
  }
341
573
 
342
- // Refresh only the captured session model. Never read live files or Git here.
574
+ // Refresh only the captured session model. Never read live files here; the
575
+ // only Git the overlay touches is each root's HEAD, to label its tree.
343
576
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
344
577
  let host: ExternalEditorHost | undefined;
345
578
  let view: WorktreeChangesView | undefined;
579
+ // Session evidence knows roots, not branches; label them while the overlay
580
+ // is open and repaint when Git answers.
581
+ const labels = new RootBranchLabels(deps.gitForRoot, () => {
582
+ view?.update(labels.decorate(deps.worktrees()));
583
+ host?.requestRender();
584
+ });
585
+ const worktrees = () => labels.decorate(deps.worktrees());
346
586
  const refresh = async () => {
347
587
  const latest = await deps.refresh();
348
- view?.update(deps.worktrees());
588
+ view?.update(worktrees());
349
589
  deps.apply(ctx, latest);
350
590
  };
351
591
  const poll = setInterval(() => void refresh(), deps.pollMs);
@@ -354,7 +594,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
354
594
  const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
355
595
  (tui, theme, _keybindings, done) => {
356
596
  host = tui;
357
- view = new WorktreeChangesView(deps.worktrees(), {
597
+ view = new WorktreeChangesView(worktrees(), {
358
598
  theme,
359
599
  rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
360
600
  loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
@@ -417,6 +657,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
417
657
  }
418
658
 
419
659
  const USAGE_COMMAND_NAME = "gentle:usage";
660
+ const USAGE_SHORTCUT_DEFAULT = "alt+u";
420
661
  const REVIEW_PREFLIGHT_TYPE = "gentle-pi.review-preflight";
421
662
  const DEV_BINARY_WIDGET_KEY = "gentle-shell-dev-binary";
422
663
  const SHA_PREFIX_LENGTH = 16;
@@ -482,21 +723,43 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
482
723
  }
483
724
  }
484
725
 
726
+ // The NaN Cloud quota endpoint is the one the official dashboard reads with the
727
+ // same API key pi already holds. The key travels in the header only: the request
728
+ // refuses redirects so it cannot be replayed to another origin, asks for no
729
+ // stored copy, and nothing here logs, renders, or persists it.
730
+ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
731
+ if (!apiKey) return undefined;
732
+ try {
733
+ const response = await fetchFn(NAN_QUOTA_URL, {
734
+ redirect: "error",
735
+ cache: "no-store",
736
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "User-Agent": "gentle-pi" },
737
+ });
738
+ if (!response.ok) return undefined;
739
+ const parsed = parseNanQuota(await response.json(), now);
740
+ return parsed.limits.length > 0 ? parsed : undefined;
741
+ } catch {
742
+ return undefined;
743
+ }
744
+ }
745
+
485
746
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
486
747
  installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
487
748
  if (!shellEnabled(env)) return;
488
749
  const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
489
750
  const usage = new UsageStore();
490
751
  let renderHost: ShellRenderHost | undefined;
491
- let usageFetchedAt = 0;
752
+ // The 5-minute rule is per provider: one provider's fetch cannot leave the
753
+ // next one waiting for an interval it never used.
754
+ const usageFetchedAt = new Map<string, number>();
492
755
  const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
493
756
  const provider = ctx.model?.provider;
494
- if (provider !== CODEX_PROVIDER) return;
757
+ if (provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
495
758
  const now = deps.now();
496
- if (!force && now - usageFetchedAt < USAGE_REFRESH_MS) return;
497
- usageFetchedAt = now;
498
- const token = await ctx.modelRegistry.getApiKeyForProvider(CODEX_PROVIDER).catch(() => undefined);
499
- const fetched = await fetchCodexUsage(token, deps.fetch, deps.now());
759
+ if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
760
+ usageFetchedAt.set(provider, now);
761
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
762
+ const fetched = provider === NAN_PROVIDER ? await fetchNanUsage(apiKey, deps.fetch, deps.now()) : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
500
763
  if (!fetched) return;
501
764
  usage.record(fetched);
502
765
  renderHost?.invalidateSidebar?.();
@@ -514,25 +777,51 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
514
777
  const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
515
778
  return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
516
779
  });
780
+ const openUsage = async (ctx: ExtensionContext) => {
781
+ await refreshUsage(ctx, true);
782
+ await ctx.ui.custom<null>(
783
+ (tui, theme, _keybindings, done) =>
784
+ new UsageView(usage, {
785
+ theme,
786
+ now: () => deps.now(),
787
+ active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
788
+ onRefresh: () => refreshUsage(ctx, true),
789
+ onClose: () => done(null),
790
+ requestRender: () => tui.requestRender(),
791
+ }),
792
+ { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
793
+ );
794
+ };
517
795
  pi.registerCommand(USAGE_COMMAND_NAME, {
518
796
  description: "Show subscription usage windows for the connected providers. Press r to refetch.",
519
- handler: async (_args, ctx) => {
520
- await refreshUsage(ctx, true);
521
- await ctx.ui.custom<null>(
522
- (tui, theme, _keybindings, done) =>
523
- new UsageView(usage, {
524
- theme,
525
- now: () => deps.now(),
526
- active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
527
- onRefresh: () => refreshUsage(ctx, true),
528
- onClose: () => done(null),
529
- requestRender: () => tui.requestRender(),
530
- }),
531
- { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
532
- );
533
- },
797
+ handler: async (_args, ctx) => openUsage(ctx),
534
798
  });
799
+ const usageShortcutKey = usageShortcut(env);
800
+ if (usageShortcutKey) {
801
+ pi.registerShortcut(usageShortcutKey as Parameters<ExtensionAPI["registerShortcut"]>[0], {
802
+ description: "Show subscription usage windows for the connected providers",
803
+ handler: async (ctx) => openUsage(ctx),
804
+ });
805
+ }
535
806
  let prompt: GentlePromptEditor | undefined;
807
+ // Set by abortAndDispatchQueued via dispatchQueuedText when an Esc aborts
808
+ // a turn with a non-empty queue; sent exactly once, from agent_settled,
809
+ // once the aborted run has fully settled (issue #1218). Several aborts
810
+ // before that settle append in order, joined the way Pi joins its own
811
+ // queue, so nothing is overwritten. It belongs to the current session and
812
+ // is dropped on session_shutdown.
813
+ let pendingQueuedText: string | undefined;
814
+ // Resolved once at startup and cached in memory so the editor never
815
+ // re-reads the file per keypress. The /gentle:double-esc-cancel command
816
+ // below is the only place that touches the file, and every invocation
817
+ // re-syncs this cache from disk first, so status, the no-argument toggle
818
+ // direction, and the Esc gate always describe the same effective policy
819
+ // even when another session or a hand edit changed the file mid-session.
820
+ const doubleEscCancelConfigHome = gentlePiConfigHome(env);
821
+ let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
822
+ env,
823
+ gentlePiConfigHome: doubleEscCancelConfigHome,
824
+ }).policy;
536
825
  let changes: SessionChanges | undefined;
537
826
  let registry: SessionWorktreeRegistry | undefined;
538
827
  let currentContext: ExtensionContext | undefined;
@@ -594,14 +883,34 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
594
883
  render: (width) => renderShellSidebarBar(footerModel(), theme, width),
595
884
  invalidate() {},
596
885
  });
886
+ // The header row carries everything that ticks every frame (model,
887
+ // effort, context, cost, usage) plus session identity; it never sees
888
+ // extension statuses or the working/thinking state.
889
+ const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
890
+ const disposeHeader = sidebarHeader(tui, {
891
+ digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
892
+ render: (width) => [headerBar(width).text],
893
+ invalidate() {},
894
+ handleMouse(event) {
895
+ if (event.type !== "click" || event.button !== "left") return undefined;
896
+ const { usageSpan } = headerBar(event.width);
897
+ if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
898
+ void openUsage(ctx);
899
+ return { handled: true, render: true };
900
+ },
901
+ });
597
902
  const uninstall = installSidebar(tui, theme);
598
- return { ...part, dispose() { uninstall(); part.dispose(); } };
903
+ return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
599
904
  });
600
905
  void refreshUsage(ctx, true);
601
- const ownsPrompt = installPrompt(ctx, (created) => {
602
- prompt?.dispose();
603
- prompt = created;
604
- });
906
+ const ownsPrompt = installPrompt(
907
+ ctx,
908
+ (created) => {
909
+ prompt?.dispose();
910
+ prompt = created;
911
+ },
912
+ { now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
913
+ );
605
914
  // Hide native feedback only when our petal replaces it. Native transcript
606
915
  // thinking blocks remain Pi-owned; this changes only the supported loader UI.
607
916
  if (ownsPrompt) ctx.ui.setWorkingVisible(false);
@@ -617,6 +926,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
617
926
  applyChanges(ctx, tracker.model);
618
927
  });
619
928
  pi.on("session_shutdown", (_event, ctx) => {
929
+ pendingQueuedText = undefined;
620
930
  prompt?.dispose();
621
931
  prompt = undefined;
622
932
  if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
@@ -637,7 +947,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
637
947
  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");
638
948
  return;
639
949
  }
640
- await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
950
+ 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) });
641
951
  };
642
952
  pi.registerCommand(CHANGES_COMMAND_NAME, {
643
953
  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.",
@@ -661,13 +971,73 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
661
971
  handler: async (ctx) => showCommandPalette(pi, ctx, env),
662
972
  });
663
973
  }
974
+ // User-owned, like gentle:background-subagents and gentle:review-mode: the
975
+ // only writer is this handler, reached only by explicit invocation. Unlike
976
+ // those two, no argument toggles the effective policy instead of merely
977
+ // reporting it (see odd/tasks/double-esc-cancel.md).
978
+ pi.registerCommand(DOUBLE_ESC_CANCEL_COMMAND_NAME, {
979
+ description: "Show or set the double-esc-cancel preference (status|enable|disable); no argument toggles it. User-initiated only.",
980
+ handler: async (args, ctx) => {
981
+ const trimmed = args.trim();
982
+ if (trimmed !== "" && trimmed !== "status" && trimmed !== "enable" && trimmed !== "disable") {
983
+ ctx.ui.notify(`Unknown /${DOUBLE_ESC_CANCEL_COMMAND_NAME} sub-action "${trimmed}". Use status, enable, or disable.`, "warning");
984
+ return;
985
+ }
986
+ try {
987
+ const before = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
988
+ doubleEscCancelPolicy = before.policy;
989
+ const subAction = trimmed === "" ? (before.policy === "on" ? "disable" : "enable") : trimmed;
990
+ if (subAction === "status") {
991
+ const report = renderDoubleEscCancelReport(before);
992
+ ctx.ui.notify(report.message, report.type);
993
+ return;
994
+ }
995
+ const wrote: DoubleEscCancelPolicy = subAction === "enable" ? "on" : "off";
996
+ writeDoubleEscCancelPolicy(wrote, { gentlePiConfigHome: doubleEscCancelConfigHome });
997
+ const after = resolveDoubleEscCancelPolicy({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
998
+ // Cache what the file actually resolves to, not what was written: a
999
+ // competing writer or a read failure would otherwise leave the gate
1000
+ // and the report disagreeing.
1001
+ doubleEscCancelPolicy = after.policy;
1002
+ const report = renderDoubleEscCancelReport(after, wrote);
1003
+ ctx.ui.notify(report.message, report.type);
1004
+ } catch (error) {
1005
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1006
+ }
1007
+ },
1008
+ });
664
1009
  pi.on("agent_start", (_event, ctx) => {
1010
+ // A turn can start any other way (the user sending the draft, an
1011
+ // extension, a shortcut) before the aborted run's own agent_settled
1012
+ // below has delivered the pending text. Nothing is sent from here: Pi
1013
+ // is mid-turn, so the text simply waits and goes out, once, when that
1014
+ // turn settles. It is never dropped.
665
1015
  prompt?.setWorking(true);
666
1016
  // The dev-binary card is a startup notice: it leaves with the first prompt.
667
1017
  if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
668
1018
  });
669
- pi.on("agent_settled", () => {
1019
+ pi.on("agent_settled", (_event, ctx) => {
1020
+ // Pi clears its own run-active flag before emitting agent_settled, so
1021
+ // this is normally idle; if a run is somehow still in flight the prompt
1022
+ // stays working and the pending text waits for the next settle.
1023
+ if (!ctx.isIdle()) return;
670
1024
  prompt?.setWorking(false);
1025
+ if (pendingQueuedText === undefined) return;
1026
+ const queued = pendingQueuedText;
1027
+ pendingQueuedText = undefined;
1028
+ try {
1029
+ pi.sendUserMessage(queued);
1030
+ } catch (error) {
1031
+ // Never drop the user's words: put them back in front of the draft,
1032
+ // exactly the shape Pi's own restore would have left, and say why.
1033
+ if (prompt) {
1034
+ const current = prompt.getText();
1035
+ prompt.setText([queued, current].filter((text) => text.trim() !== "").join("\n\n"));
1036
+ } else {
1037
+ pendingQueuedText = queued;
1038
+ }
1039
+ 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");
1040
+ }
671
1041
  });
672
1042
  pi.on("agent_end", async (_event, ctx) => {
673
1043
  await refreshChanges(ctx);
@@ -104,19 +104,37 @@ export default function gentleTodo(pi: ExtensionAPI, env: NodeJS.ProcessEnv = pr
104
104
  };
105
105
 
106
106
  const todoCard = (current: TodoSession, theme: Parameters<typeof renderTodoCard>[1], scrollable: boolean, spacer: boolean): Component & { dispose(): void } => {
107
+ let hovered = false;
107
108
  const card: Component = {
108
109
  render(width: number) {
109
110
  const lines = renderTodoCard(current.state, theme, width, {
110
111
  collapsed: current.collapsed,
111
112
  staleTurns: staleTurns(current.state, current.turn),
112
113
  collapseKey,
114
+ hovered,
113
115
  ...(scrollable ? { scrollable: true } : {}),
114
116
  });
115
117
  return spacer && lines.length > 0 ? [...lines, ""] : lines;
116
118
  },
117
- invalidate() {},
119
+ invalidate() {
120
+ hovered = false;
121
+ },
118
122
  };
119
123
  const region = new NativePointerRegion(card, {
124
+ onHover(event) {
125
+ // The region spans the whole card, but only the header row (y===0)
126
+ // is the clickable control, so a move elsewhere in the card clears
127
+ // hover exactly like leaving the region entirely would.
128
+ const next = event.y === 0;
129
+ if (next === hovered) return { handled: true };
130
+ hovered = next;
131
+ return { handled: true, render: true };
132
+ },
133
+ onLeave() {
134
+ if (!hovered) return;
135
+ hovered = false;
136
+ current.host?.requestRender();
137
+ },
120
138
  onClick(event) {
121
139
  if (event.button !== "left" || event.y !== 0) return undefined;
122
140
  toggle(current);