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
@@ -1,12 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync, execFile } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
8
- import type { TUI } from "@earendil-works/pi-tui";
9
- import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
8
+ import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
9
+ import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
10
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
11
  import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
12
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
@@ -53,6 +53,7 @@ interface GitScript {
53
53
  }
54
54
 
55
55
  interface CommandRegistration {
56
+ description?: string;
56
57
  handler: (args: string, ctx: ExtensionContext) => Promise<void>;
57
58
  }
58
59
 
@@ -128,7 +129,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
128
129
  for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
129
130
  }
130
131
 
131
- function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
132
+ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
132
133
  const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
133
134
  let resolveOverlay: () => void;
134
135
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
@@ -136,6 +137,7 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
136
137
  const ctx = {
137
138
  hasUI: options.hasUI ?? true,
138
139
  hasPendingMessages: () => options.pending ?? false,
140
+ isIdle: () => options.idle ?? true,
139
141
  cwd: "/repo",
140
142
  model: { id: "gpt-5.5", provider: "openai-codex", reasoning: true, contextWindow: 272_000 },
141
143
  sessionManager: {
@@ -253,12 +255,11 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
253
255
  assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
254
256
  });
255
257
 
256
- test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
258
+ test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => {
257
259
  const { pi, handlers } = fakePi();
258
260
  let profile: string | undefined = "team";
259
261
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
260
- const entries: unknown[] = [];
261
- const { ctx, ui } = fakeContext({ entries });
262
+ const { ctx, ui } = fakeContext();
262
263
  await fire(handlers, "session_start", ctx);
263
264
 
264
265
  const statuses = new Map<string, string>();
@@ -270,7 +271,7 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
270
271
  const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail;
271
272
  const live = () => rail.digest?.();
272
273
  assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
273
- assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
274
+ assert.doesNotMatch(rail.render(46).join("\n"), /gpt-5\.5/, "model now lives in the header, not the Status card");
274
275
 
275
276
  assert.match(rail.render(46).join("\n"), /Profile.*team/);
276
277
  const beforeProfile = live();
@@ -280,26 +281,79 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
280
281
  profile = undefined;
281
282
  assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
282
283
 
284
+ const beforeStatus = live();
285
+ statuses.set("mcp", "MCP: 3 servers enabled");
286
+ assert.notEqual(live(), beforeStatus, "extension statuses have no event and must change the digest");
287
+ assert.match(rail.render(46).join("\n"), /MCP: 3 servers enabled/);
288
+ assert.equal(live(), live(), "an unchanged digest still reuses the prepared rail");
289
+ } finally {
290
+ component.dispose();
291
+ }
292
+ });
293
+
294
+ test("the fullscreen header rail carries a live digest so model, context, and cost changes refresh it", async () => {
295
+ const { pi, handlers } = fakePi();
296
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
297
+ const entries: unknown[] = [];
298
+ const { ctx, ui } = fakeContext({ entries });
299
+ await fire(handlers, "session_start", ctx);
300
+
301
+ const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map([["mcp", "MCP: 3 servers enabled"]]), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
302
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
303
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
304
+ const component = factory(tui, plainTheme, liveFooterData);
305
+ try {
306
+ const header = sidebarState(tui as unknown as TUI).parts.get("header") as SidebarRail;
307
+ assert.equal(typeof header.digest, "function", "the header paints live state every frame and must declare a digest");
308
+ const live = () => header.digest?.();
309
+ const text = () => header.render(160).join("\n");
310
+ assert.match(text(), /gpt-5\.5/);
311
+ assert.doesNotMatch(text(), /MCP: 3 servers/, "extension statuses never reach the header");
312
+ assert.doesNotMatch(text(), /working/i, "the working state never reaches the header");
313
+
283
314
  const beforeModel = live();
284
315
  (ctx.model as { id: string }).id = "gpt-5.6";
285
- assert.notEqual(live(), beforeModel, "/model must change the digest");
286
- assert.match(rail.render(46).join("\n"), /gpt-5\.6/);
316
+ assert.notEqual(live(), beforeModel, "/model must change the header digest");
317
+ assert.match(text(), /gpt-5\.6/);
287
318
 
288
319
  const beforeUsage = live();
289
320
  (ctx as unknown as { getContextUsage: () => unknown }).getContextUsage = () => ({ tokens: 200_000, contextWindow: 272_000, percent: 74 });
290
- assert.notEqual(live(), beforeUsage, "context usage must change the digest");
291
- assert.match(rail.render(46).join("\n"), /74%/);
321
+ assert.notEqual(live(), beforeUsage, "context usage must change the header digest");
322
+ assert.match(text(), /74%/);
292
323
 
293
324
  const beforeCost = live();
294
325
  entries.push(assistantEntry({ input: 100, output: 20, cost: 0.42 }));
295
- assert.notEqual(live(), beforeCost, "session cost must change the digest");
296
- assert.match(rail.render(46).join("\n"), /\$0\.420/);
326
+ assert.notEqual(live(), beforeCost, "session cost must change the header digest");
327
+ assert.match(text(), /\$0\.420/);
328
+ assert.equal(live(), live(), "an unchanged digest still reuses the prepared header");
329
+ } finally {
330
+ component.dispose();
331
+ }
332
+ });
297
333
 
298
- const beforeStatus = live();
299
- statuses.set("mcp", "MCP: 3 servers enabled");
300
- assert.notEqual(live(), beforeStatus, "extension statuses have no event and must change the digest");
301
- assert.match(rail.render(46).join("\n"), /MCP: 3 servers enabled/);
302
- assert.equal(live(), live(), "an unchanged digest still reuses the prepared rail");
334
+ test("clicking the header's usage segment opens the usage panel; other header clicks are ignored", async () => {
335
+ const { pi, handlers } = fakePi();
336
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
337
+ const { ctx, ui } = fakeContext();
338
+ await fire(handlers, "session_start", ctx);
339
+
340
+ const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
341
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
342
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
343
+ const component = factory(tui, plainTheme, liveFooterData);
344
+ try {
345
+ const header = sidebarState(tui as unknown as TUI).parts.get("header") as SidebarRail;
346
+ const line = header.render(160).join("");
347
+ const usageAt = line.indexOf("usage");
348
+ assert.ok(usageAt >= 0, "the header shows a standing usage segment");
349
+
350
+ const click = (x: number) => header.handleMouse?.({ type: "click", button: "left", x, y: 0, screenX: x, screenY: 0, width: 160, height: 1, shift: false, alt: false, ctrl: false } as TuiMouseEvent);
351
+ assert.equal(click(0), undefined, "a click on the brand does not open the panel");
352
+ const hit = click(usageAt + 1);
353
+ assert.equal(hit?.handled, true);
354
+ await new Promise((resolve) => setTimeout(resolve, 0));
355
+ assert.match(ui.overlayView!.render(90).join("\n"), /Subscriptions/);
356
+ ui.closeOverlay?.();
303
357
  } finally {
304
358
  component.dispose();
305
359
  }
@@ -355,10 +409,15 @@ const fakeTui = { terminal: { rows: 40, columns: 120 }, requestRender() {} };
355
409
  const editorTheme = { borderColor: (text: string) => text, selectList: {} };
356
410
  const fakeKeybindings = { matches: () => false };
357
411
 
358
- function installedPrompt(ctx: ExtensionContext, ui: FakeUi, handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>): GentlePromptEditor {
412
+ function installedPrompt(
413
+ ctx: ExtensionContext,
414
+ ui: FakeUi,
415
+ handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>,
416
+ keybindings: unknown = fakeKeybindings,
417
+ ): GentlePromptEditor {
359
418
  for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
360
419
  const factory = ui.editorFactory as (tui: unknown, theme: unknown, keybindings: unknown) => GentlePromptEditor;
361
- return factory(fakeTui, editorTheme, fakeKeybindings);
420
+ return factory(fakeTui, editorTheme, keybindings);
362
421
  }
363
422
 
364
423
  test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
@@ -440,6 +499,673 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
440
499
  assert.equal(active, 0);
441
500
  });
442
501
 
502
+ // ---------------------------------------------------------------------------
503
+ // double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
504
+ // is working and autocomplete is hidden, the first Esc is swallowed and the
505
+ // frame shows a hint; a second Esc within the window falls through to
506
+ // CustomEditor's own handleInput so Pi's onEscape performs the abort exactly
507
+ // as it always has. Idle double-Esc (tree/fork), bash-mode Esc, and
508
+ // autocomplete cancel live entirely in Pi's own onEscape/CustomEditor and are
509
+ // untouched by this gate.
510
+ // ---------------------------------------------------------------------------
511
+
512
+ const escapeKeybindings = { matches: (_data: string, keybinding: string) => keybinding === "app.interrupt" };
513
+
514
+ test("double-esc-cancel default off: a single Esc while working still aborts immediately, exactly as before", (t) => {
515
+ const { pi, handlers } = fakePi();
516
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
517
+ const { ctx, ui } = fakeContext();
518
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
519
+ let aborted = 0;
520
+ editor.onEscape = () => { aborted++; };
521
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
522
+ editor.handleInput("\x1b");
523
+ assert.equal(aborted, 1);
524
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
525
+ editor.dispose();
526
+ });
527
+
528
+ function scopedDoubleEscCancelConfigHome(t: { after(callback: () => void): void }): string {
529
+ const configHome = mkdtempSync(join(tmpdir(), "gp-esc-cfg-"));
530
+ t.after(() => rmSync(configHome, { recursive: true, force: true }));
531
+ return configHome;
532
+ }
533
+
534
+ test("double-esc-cancel enabled: the first Esc while working is swallowed and shows the hint instead of aborting", (t) => {
535
+ const configHome = scopedDoubleEscCancelConfigHome(t);
536
+ const { pi, handlers } = fakePi();
537
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
538
+ const { ctx, ui } = fakeContext();
539
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
540
+ let aborted = 0;
541
+ editor.onEscape = () => { aborted++; };
542
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
543
+ editor.handleInput("\x1b");
544
+ assert.equal(aborted, 0, "the first Esc must be swallowed, not aborted");
545
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
546
+ editor.dispose();
547
+ });
548
+
549
+ test("double-esc-cancel enabled: a second Esc within the window falls through and aborts, clearing the hint", (t) => {
550
+ let now = 1_000_000;
551
+ const configHome = scopedDoubleEscCancelConfigHome(t);
552
+ const { pi, handlers } = fakePi();
553
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
554
+ const { ctx, ui } = fakeContext();
555
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
556
+ let aborted = 0;
557
+ editor.onEscape = () => { aborted++; };
558
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
559
+ editor.handleInput("\x1b");
560
+ assert.equal(aborted, 0);
561
+ now += 500;
562
+ editor.handleInput("\x1b");
563
+ assert.equal(aborted, 1, "the second Esc within the window must fall through to abort");
564
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
565
+ editor.dispose();
566
+ });
567
+
568
+ test("double-esc-cancel enabled: an Esc after the window expires is a fresh first press, not an abort", (t) => {
569
+ let now = 1_000_000;
570
+ const configHome = scopedDoubleEscCancelConfigHome(t);
571
+ const { pi, handlers } = fakePi();
572
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
573
+ const { ctx, ui } = fakeContext();
574
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
575
+ let aborted = 0;
576
+ editor.onEscape = () => { aborted++; };
577
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
578
+ editor.handleInput("\x1b");
579
+ assert.equal(aborted, 0);
580
+ now += 1001;
581
+ editor.handleInput("\x1b");
582
+ assert.equal(aborted, 0, "the window expired, so this must be treated as a new first press");
583
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
584
+ editor.dispose();
585
+ });
586
+
587
+ test("double-esc-cancel enabled: Esc while idle passes straight through, untouched by this gate", (t) => {
588
+ const configHome = scopedDoubleEscCancelConfigHome(t);
589
+ const { pi, handlers } = fakePi();
590
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
591
+ const { ctx, ui } = fakeContext();
592
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
593
+ let aborted = 0;
594
+ editor.onEscape = () => { aborted++; };
595
+ // agent_start never fired: the prompt stays idle.
596
+ editor.handleInput("\x1b");
597
+ assert.equal(aborted, 1, "idle Esc must be unaffected by the policy");
598
+ editor.dispose();
599
+ });
600
+
601
+ test("double-esc-cancel enabled: Esc while autocomplete is visible bypasses this gate entirely", (t) => {
602
+ const configHome = scopedDoubleEscCancelConfigHome(t);
603
+ const { pi, handlers } = fakePi();
604
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
605
+ const { ctx, ui } = fakeContext();
606
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
607
+ (editor as unknown as { isShowingAutocomplete(): boolean }).isShowingAutocomplete = () => true;
608
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
609
+ editor.handleInput("\x1b");
610
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/, "autocomplete must bypass the gate, matching CustomEditor's own guard");
611
+ editor.dispose();
612
+ });
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // Working-cancel keeps the queue moving (issue #1218). Pi's own onEscape
616
+ // restores `[queuedText, currentText].filter(t => t.trim()).join("\n\n")`
617
+ // into the editor and aborts. These tests fake that join in `onEscape` (the
618
+ // same seam the double-esc-cancel tests above use), then check that the
619
+ // user's own draft is all that is left in the editor and that the queued
620
+ // text is sent exactly once, after the aborted run settles.
621
+ // ---------------------------------------------------------------------------
622
+
623
+ test("extractQueuedText reverses Pi's queued+draft join", () => {
624
+ assert.equal(extractQueuedText("follow up\n\ndraft reply", "draft reply"), "follow up");
625
+ assert.equal(extractQueuedText("draft reply", "draft reply"), "", "no queue: the join degenerates to the draft alone");
626
+ assert.equal(extractQueuedText("only queued", ""), "only queued", "empty draft: the join degenerates to the queue alone");
627
+ assert.equal(extractQueuedText("", ""), "", "both empty");
628
+ assert.equal(extractQueuedText("unrelated text", "draft reply"), undefined, "a shape that does not match the join is unrecognized, not an empty queue: Pi's own text must win, not be discarded");
629
+ });
630
+
631
+ test("extractQueuedText treats a whitespace-only draft as empty, matching Pi's own trim filter", () => {
632
+ assert.equal(extractQueuedText("only queued", " "), "only queued", "a whitespace-only draft never survives Pi's filter, so the whole join is the queue");
633
+ assert.equal(extractQueuedText("", " "), "", "whitespace-only draft with no queue is still no queue, not unrecognized");
634
+ });
635
+
636
+ test("working cancel: an aborted turn with a queued message sends it once settled and keeps the draft", (t) => {
637
+ const { pi, handlers, sentMessages } = fakePi();
638
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
639
+ const { ctx, ui } = fakeContext();
640
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
641
+ editor.setText("draft reply");
642
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
643
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
644
+ editor.handleInput("\x1b");
645
+ assert.equal(editor.getText(), "draft reply", "the user's own draft stays in the editor");
646
+ assert.equal(sentMessages.length, 0, "nothing is sent until the aborted run settles");
647
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
648
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
649
+ editor.dispose();
650
+ });
651
+
652
+ test("working cancel: an aborted turn with no queued messages behaves exactly as before, with no redundant setText", (t) => {
653
+ const { pi, handlers, sentMessages } = fakePi();
654
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
655
+ const { ctx, ui } = fakeContext();
656
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
657
+ editor.setText("draft reply");
658
+ // Pi's own onEscape restore is the one real setText call on this path
659
+ // (it always writes the combined text, even when that equals the
660
+ // draft); the spy below counts every call, so it must see only that one
661
+ // and none added by abortAndDispatchQueued itself.
662
+ editor.onEscape = () => { editor.setText(editor.getText()); };
663
+ let setTextCalls = 0;
664
+ const originalSetText = editor.setText.bind(editor);
665
+ (editor as unknown as { setText(text: string): void }).setText = (text: string) => { setTextCalls++; originalSetText(text); };
666
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
667
+ editor.handleInput("\x1b");
668
+ assert.equal(setTextCalls, 1, "the no-queue path must not add its own write on top of Pi's own restore");
669
+ assert.equal(editor.getText(), "draft reply");
670
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
671
+ assert.equal(sentMessages.length, 0, "an empty queue must never trigger a send");
672
+ editor.dispose();
673
+ });
674
+
675
+ test("working cancel: an unrecognized restore shape leaves Pi's own text untouched and dispatches nothing", (t) => {
676
+ const { pi, handlers, sentMessages } = fakePi();
677
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
678
+ const { ctx, ui } = fakeContext();
679
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
680
+ editor.setText("draft reply");
681
+ // Simulates a future Pi restore shape this code does not recognize.
682
+ editor.onEscape = () => { editor.setText("an unrecognized shape"); };
683
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
684
+ editor.handleInput("\x1b");
685
+ assert.equal(editor.getText(), "an unrecognized shape", "Pi's own restore wins on a shape mismatch; nothing here overwrites it");
686
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
687
+ assert.equal(sentMessages.length, 0, "an unrecognized shape must never be dispatched as if it were queued text");
688
+ editor.dispose();
689
+ });
690
+
691
+ test("working cancel: an empty draft with a queued message sends the whole queue and leaves the editor empty", (t) => {
692
+ const { pi, handlers, sentMessages } = fakePi();
693
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
694
+ const { ctx, ui } = fakeContext();
695
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
696
+ editor.onEscape = () => { editor.setText("only queued"); };
697
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
698
+ editor.handleInput("\x1b");
699
+ assert.equal(editor.getText(), "", "an empty draft stays empty");
700
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
701
+ assert.deepEqual(sentMessages.map((m) => m.content), ["only queued"]);
702
+ editor.dispose();
703
+ });
704
+
705
+ test("working cancel: the same dispatch applies to the confirming second Esc when double-esc-cancel is enabled", (t) => {
706
+ let now = 1_000_000;
707
+ const configHome = scopedDoubleEscCancelConfigHome(t);
708
+ const { pi, handlers, sentMessages } = fakePi();
709
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
710
+ const { ctx, ui } = fakeContext();
711
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
712
+ editor.setText("draft reply");
713
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
714
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
715
+ editor.handleInput("\x1b");
716
+ now += 500;
717
+ editor.handleInput("\x1b");
718
+ assert.equal(editor.getText(), "draft reply");
719
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
720
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
721
+ editor.dispose();
722
+ });
723
+
724
+ test("working cancel: queued text is never sent twice even if agent_settled fires again", (t) => {
725
+ const { pi, handlers, sentMessages } = fakePi();
726
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
727
+ const { ctx, ui } = fakeContext();
728
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
729
+ editor.setText("draft reply");
730
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
731
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
732
+ editor.handleInput("\x1b");
733
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
734
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
735
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
736
+ editor.dispose();
737
+ });
738
+
739
+ test("working cancel: a new agent_start before the aborted run settles keeps the pending text and sends it once that turn settles", (t) => {
740
+ const { pi, handlers, sentMessages } = fakePi();
741
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
742
+ const { ctx, ui } = fakeContext();
743
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
744
+ editor.setText("draft reply");
745
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
746
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
747
+ editor.handleInput("\x1b");
748
+ // The user sent the draft (or another turn started) before the aborted
749
+ // run's own agent_settled fired. Nothing is sent from inside agent_start:
750
+ // Pi is mid-turn there, so the text waits for that turn to settle.
751
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
752
+ assert.equal(sentMessages.length, 0, "never send from inside agent_start");
753
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
754
+ assert.deepEqual(sentMessages.map((m) => ({ content: m.content, deliverAs: m.options?.deliverAs })), [{ content: "follow up", deliverAs: undefined }]);
755
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
756
+ assert.equal(sentMessages.length, 1, "the pending text must not be delivered a second time");
757
+ editor.dispose();
758
+ });
759
+
760
+ test("working cancel: a whitespace-only recognized queue is treated as no queue and never dispatched", (t) => {
761
+ const { pi, handlers, sentMessages } = fakePi();
762
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
763
+ const { ctx, ui } = fakeContext();
764
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
765
+ editor.setText("draft reply");
766
+ editor.onEscape = () => { editor.setText(` \n\n${editor.getText()}`); };
767
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
768
+ editor.handleInput("\x1b");
769
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
770
+ assert.equal(sentMessages.length, 0, "whitespace is not a message");
771
+ assert.equal(editor.getText(), "draft reply", "the recognized whitespace prefix is stripped and the draft comes back alone");
772
+ editor.dispose();
773
+ });
774
+
775
+ test("working cancel: a failing sendUserMessage on settle is reported, not thrown, and the prompt still leaves the working state", (t) => {
776
+ const { pi, handlers } = fakePi();
777
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
778
+ const { ctx, ui } = fakeContext();
779
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
780
+ editor.setText("draft reply");
781
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
782
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
783
+ editor.handleInput("\x1b");
784
+ (pi as unknown as { sendUserMessage: (content: string) => void }).sendUserMessage = () => { throw new Error("session is switching"); };
785
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
786
+ assert.ok(ui.notices.some((notice) => /session is switching/.test(notice)), "the failure surfaces as a notice");
787
+ assert.equal(editor.getText(), "follow up\n\ndraft reply", "the queued text is put back in front of the draft, exactly as Pi's own restore would have left it");
788
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/, "the prompt is idle again");
789
+ editor.dispose();
790
+ });
791
+
792
+ test("working cancel: a failing send reported through a context without UI still restores the queued text into the editor", (t) => {
793
+ const { pi, handlers } = fakePi();
794
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
795
+ const { ctx, ui } = fakeContext();
796
+ const headless = fakeContext({ hasUI: false });
797
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
798
+ editor.setText("draft reply");
799
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
800
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
801
+ editor.handleInput("\x1b");
802
+ (pi as unknown as { sendUserMessage: (content: string) => void }).sendUserMessage = () => { throw new Error("session is switching"); };
803
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, headless.ctx);
804
+ assert.equal(headless.ui.notices.length, 0, "no UI, no notice");
805
+ assert.equal(editor.getText(), "follow up\n\ndraft reply", "with or without a UI to tell the user, the text is never dropped");
806
+ editor.dispose();
807
+ });
808
+
809
+ test("working cancel: agent_settled never sends while another turn is still in flight; the text waits for an idle settle", (t) => {
810
+ const { pi, handlers, sentMessages } = fakePi();
811
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
812
+ const { ctx, ui } = fakeContext();
813
+ const busy = fakeContext({ idle: false }).ctx;
814
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
815
+ editor.setText("draft reply");
816
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
817
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
818
+ editor.handleInput("\x1b");
819
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, busy);
820
+ assert.equal(sentMessages.length, 0, "a settle reported while not idle must not inject the text mid-turn");
821
+ let aborted = 0;
822
+ editor.onEscape = () => { aborted++; };
823
+ editor.handleInput("\x1b");
824
+ assert.equal(aborted, 1, "a run is still in flight, so the prompt stays working and Esc takes the cancel path");
825
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "not the idle clear");
826
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
827
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
828
+ editor.dispose();
829
+ });
830
+
831
+ test("working cancel: two aborts before an idle settle deliver both queued texts, in order, as one message", (t) => {
832
+ const { pi, handlers, sentMessages } = fakePi();
833
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
834
+ const { ctx, ui } = fakeContext();
835
+ const busy = fakeContext({ idle: false }).ctx;
836
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
837
+ let aborts = 0;
838
+ editor.onEscape = () => { aborts++; editor.setText(aborts === 1 ? "first" : "second"); };
839
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
840
+ editor.handleInput("\x1b");
841
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, busy);
842
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
843
+ editor.handleInput("\x1b");
844
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
845
+ assert.deepEqual(sentMessages.map((m) => m.content), ["first\n\nsecond"], "nothing from the first abort is lost, and order is preserved");
846
+ editor.dispose();
847
+ });
848
+
849
+ test("working cancel: pending queued text never crosses a session boundary", (t) => {
850
+ const { pi, handlers, sentMessages } = fakePi();
851
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
852
+ const { ctx, ui } = fakeContext();
853
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
854
+ editor.setText("draft reply");
855
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
856
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
857
+ editor.handleInput("\x1b");
858
+ for (const handler of handlers.get("session_shutdown") ?? []) handler({}, ctx);
859
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
860
+ assert.equal(sentMessages.length, 0, "the old session's queued text must not be sent into the next session");
861
+ });
862
+
863
+ test("working cancel: agent_settled firing first delivers the pending text without a deliverAs override", (t) => {
864
+ const { pi, handlers, sentMessages } = fakePi();
865
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
866
+ const { ctx, ui } = fakeContext();
867
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
868
+ editor.setText("draft reply");
869
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
870
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
871
+ editor.handleInput("\x1b");
872
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
873
+ assert.deepEqual(sentMessages.map((m) => ({ content: m.content, deliverAs: m.options?.deliverAs })), [{ content: "follow up", deliverAs: undefined }]);
874
+ // The next turn's own agent_start must not re-deliver the already-sent text.
875
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
876
+ assert.equal(sentMessages.length, 1);
877
+ editor.dispose();
878
+ });
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // Idle double-Esc clears the draft (issue #1218). With the prompt idle,
882
+ // autocomplete hidden, and a non-empty draft, a first Esc shows a hint
883
+ // instead of doing nothing; a second Esc within 500ms adds the draft to
884
+ // history and clears it. An empty editor, autocomplete, or the working state
885
+ // are all untouched: Pi's own idle double-Esc (tree/fork) and the
886
+ // working-cancel gate above keep deciding those cases.
887
+ // ---------------------------------------------------------------------------
888
+
889
+ test("idle draft: the first Esc shows the clear hint and keeps the text", (t) => {
890
+ const { pi, handlers } = fakePi();
891
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
892
+ const { ctx, ui } = fakeContext();
893
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
894
+ editor.setText("draft reply");
895
+ let aborted = 0;
896
+ editor.onEscape = () => { aborted++; };
897
+ editor.handleInput("\x1b");
898
+ assert.equal(aborted, 0, "the first Esc must be swallowed, not passed to Pi's own idle handler");
899
+ assert.equal(editor.getText(), "draft reply");
900
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
901
+ editor.dispose();
902
+ });
903
+
904
+ test("idle draft: a second Esc within the window adds the draft to history and clears it", (t) => {
905
+ let now = 1_000_000;
906
+ const { pi, handlers } = fakePi();
907
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
908
+ const { ctx, ui } = fakeContext();
909
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
910
+ editor.setText("draft reply");
911
+ const history: string[] = [];
912
+ (editor as unknown as { addToHistory(text: string): void }).addToHistory = (text: string) => history.push(text);
913
+ editor.handleInput("\x1b");
914
+ now += 400;
915
+ editor.handleInput("\x1b");
916
+ assert.deepEqual(history, ["draft reply"]);
917
+ assert.equal(editor.getText(), "");
918
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
919
+ editor.dispose();
920
+ });
921
+
922
+ test("idle draft: an Esc after the window expires is a fresh first press, not a clear", (t) => {
923
+ let now = 1_000_000;
924
+ const { pi, handlers } = fakePi();
925
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
926
+ const { ctx, ui } = fakeContext();
927
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
928
+ editor.setText("draft reply");
929
+ editor.handleInput("\x1b");
930
+ now += 501;
931
+ editor.handleInput("\x1b");
932
+ assert.equal(editor.getText(), "draft reply", "the window expired, so this is a new first press, not a clear");
933
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
934
+ editor.dispose();
935
+ });
936
+
937
+ test("idle empty editor: Esc passes straight through to Pi's own tree/fork double-Esc", (t) => {
938
+ const { pi, handlers } = fakePi();
939
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
940
+ const { ctx, ui } = fakeContext();
941
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
942
+ let aborted = 0;
943
+ editor.onEscape = () => { aborted++; };
944
+ editor.handleInput("\x1b");
945
+ assert.equal(aborted, 1, "an empty editor must be untouched by the idle-clear gate");
946
+ editor.dispose();
947
+ });
948
+
949
+ test("idle draft: Esc while autocomplete is visible bypasses the idle-clear gate", (t) => {
950
+ const { pi, handlers } = fakePi();
951
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
952
+ const { ctx, ui } = fakeContext();
953
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
954
+ editor.setText("draft reply");
955
+ (editor as unknown as { isShowingAutocomplete(): boolean }).isShowingAutocomplete = () => true;
956
+ editor.handleInput("\x1b");
957
+ assert.equal(editor.getText(), "draft reply", "autocomplete cancel must never clear the draft");
958
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "autocomplete must bypass the idle-clear gate, matching CustomEditor's own guard");
959
+ editor.dispose();
960
+ });
961
+
962
+ test("working state: a non-empty draft's Esc is decided by the working-cancel gate, never the idle-clear hint", (t) => {
963
+ const { pi, handlers } = fakePi();
964
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
965
+ const { ctx, ui } = fakeContext();
966
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
967
+ editor.setText("draft reply");
968
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
969
+ editor.onEscape = () => { editor.setText(editor.getText()); };
970
+ editor.handleInput("\x1b");
971
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "working must never show the idle-clear hint");
972
+ editor.dispose();
973
+ });
974
+
975
+ test("idle draft: editing the text between two Esc presses starts a fresh clear window instead of clearing the edit away", (t) => {
976
+ let now = 1_000_000;
977
+ const { pi, handlers } = fakePi();
978
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
979
+ const { ctx, ui } = fakeContext();
980
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
981
+ editor.setText("draft reply");
982
+ editor.handleInput("\x1b");
983
+ editor.setText("draft reply, edited");
984
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "the edit invalidates the pending clear, so the hint goes away with it");
985
+ now += 400;
986
+ editor.handleInput("\x1b");
987
+ assert.equal(editor.getText(), "draft reply, edited", "an edit invalidates the earlier snapshot, so this must not clear");
988
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "the edit starts a fresh first press, with its own hint");
989
+ now += 400;
990
+ editor.handleInput("\x1b");
991
+ assert.equal(editor.getText(), "", "the fresh window's own second Esc, on the unchanged edited text, does clear");
992
+ editor.dispose();
993
+ });
994
+
995
+ test("idle draft: typing and deleting between two Esc presses still invalidates the pending clear", (t) => {
996
+ let now = 1_000_000;
997
+ const { pi, handlers } = fakePi();
998
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
999
+ const { ctx, ui } = fakeContext();
1000
+ // Esc-only matching: ordinary keystrokes must reach the editor as text.
1001
+ const preciseEscape = { matches: (data: string, keybinding: string) => keybinding === "app.interrupt" && data === "\x1b" };
1002
+ const editor = installedPrompt(ctx, ui, handlers, preciseEscape);
1003
+ editor.setText("draft reply");
1004
+ editor.handleInput("\x1b");
1005
+ editor.handleInput("x");
1006
+ editor.handleInput("\x7f");
1007
+ assert.equal(editor.getText(), "draft reply", "type then backspace lands on the same text");
1008
+ now += 400;
1009
+ editor.handleInput("\x1b");
1010
+ assert.equal(editor.getText(), "draft reply", "an intervening keystroke invalidates the confirmation even when the text ends up identical");
1011
+ editor.dispose();
1012
+ });
1013
+
1014
+ test("idle draft: a bash-mode draft's Esc bypasses the idle-clear gate entirely", (t) => {
1015
+ const { pi, handlers } = fakePi();
1016
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1017
+ const { ctx, ui } = fakeContext();
1018
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1019
+ editor.setText("!ls");
1020
+ let aborted = 0;
1021
+ editor.onEscape = () => { aborted++; };
1022
+ editor.handleInput("\x1b");
1023
+ assert.equal(aborted, 1, "a bash-mode draft's first Esc must reach Pi's own bash-mode onEscape, which clears bash mode");
1024
+ assert.equal(editor.getText(), "!ls", "this gate never touches bash-mode text; Pi's own onEscape owns it");
1025
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "bash mode must bypass the idle-clear gate, matching Pi's own bash-mode Esc");
1026
+ editor.dispose();
1027
+ });
1028
+
1029
+ test("idle draft: a bash-mode draft with leading whitespace still bypasses the idle-clear gate", (t) => {
1030
+ const { pi, handlers } = fakePi();
1031
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1032
+ const { ctx, ui } = fakeContext();
1033
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1034
+ editor.setText(" !ls");
1035
+ let aborted = 0;
1036
+ editor.onEscape = () => { aborted++; };
1037
+ editor.handleInput("\x1b");
1038
+ assert.equal(aborted, 1, "Pi detects bash mode from the trimmed start of the text, so this gate must match the same rule");
1039
+ editor.dispose();
1040
+ });
1041
+
1042
+ // ---------------------------------------------------------------------------
1043
+ // /gentle:double-esc-cancel (issue #1163). Unlike /gentle:background-subagents,
1044
+ // no argument toggles the effective policy rather than merely reporting it.
1045
+ // ---------------------------------------------------------------------------
1046
+
1047
+ test("gentle:double-esc-cancel is registered and declares user-initiated sub-actions with a toggling no-argument form", () => {
1048
+ const { pi, commands } = fakePi();
1049
+ gentleShell(pi, {});
1050
+ const command = commands.get("gentle:double-esc-cancel");
1051
+ assert.ok(command, "gentle:double-esc-cancel must be registered");
1052
+ assert.match(command!.description ?? "", /status\|enable\|disable/);
1053
+ assert.match(command!.description ?? "", /no argument toggles/);
1054
+ });
1055
+
1056
+ test("gentle:double-esc-cancel status reports off by default and writes nothing", async (t) => {
1057
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1058
+ const { pi, commands } = fakePi();
1059
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1060
+ const { ctx, ui } = fakeContext();
1061
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1062
+ assert.equal(ui.notices.length, 1);
1063
+ assert.match(ui.notices[0]!, /^double-esc-cancel: off \(decided by built-in default\)/);
1064
+ assert.equal(existsSync(join(configHome, "double-esc-cancel.json")), false);
1065
+ });
1066
+
1067
+ test("gentle:double-esc-cancel enable writes the global file, reports it, and takes effect immediately", async (t) => {
1068
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1069
+ const { pi, commands } = fakePi();
1070
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1071
+ const { ctx, ui } = fakeContext();
1072
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1073
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on \(decided by global file/);
1074
+ assert.match(ui.notices[0]!, /Wrote on to the global file/);
1075
+ assert.deepEqual(
1076
+ JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")),
1077
+ { schema: "gentle-pi.double-esc-cancel/v1", policy: "on" },
1078
+ );
1079
+ });
1080
+
1081
+ test("gentle:double-esc-cancel disable writes off", async (t) => {
1082
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1083
+ const { pi, commands } = fakePi();
1084
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1085
+ const { ctx, ui } = fakeContext();
1086
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1087
+ await commands.get("gentle:double-esc-cancel")!.handler("disable", ctx);
1088
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off \(decided by global file/);
1089
+ assert.deepEqual(
1090
+ JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")),
1091
+ { schema: "gentle-pi.double-esc-cancel/v1", policy: "off" },
1092
+ );
1093
+ });
1094
+
1095
+ test("gentle:double-esc-cancel with no argument toggles the effective policy each time", async (t) => {
1096
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1097
+ const { pi, commands } = fakePi();
1098
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1099
+ const { ctx, ui } = fakeContext();
1100
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1101
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on /, "off -> on on the first toggle");
1102
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1103
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off /, "on -> off on the second toggle");
1104
+ });
1105
+
1106
+ test("gentle:double-esc-cancel reports a malformed global file as fail-closed, not as an ordinary off", async (t) => {
1107
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1108
+ mkdirSync(configHome, { recursive: true });
1109
+ writeFileSync(join(configHome, "double-esc-cancel.json"), "{malformed");
1110
+ const { pi, commands } = fakePi();
1111
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1112
+ const { ctx, ui } = fakeContext();
1113
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1114
+ assert.match(ui.notices[0]!, /present but malformed/);
1115
+ });
1116
+
1117
+ test("gentle:double-esc-cancel an unknown sub-action warns and changes nothing", async (t) => {
1118
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1119
+ const { pi, commands } = fakePi();
1120
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1121
+ const { ctx, ui } = fakeContext();
1122
+ await commands.get("gentle:double-esc-cancel")!.handler("toggle", ctx);
1123
+ assert.match(ui.notices[0]!, /Unknown \/gentle:double-esc-cancel sub-action "toggle"/);
1124
+ assert.equal(existsSync(join(configHome, "double-esc-cancel.json")), false);
1125
+ });
1126
+
1127
+ test("gentle:double-esc-cancel enable updates the in-memory policy so an already-installed prompt picks it up without re-reading the file", async (t) => {
1128
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1129
+ const { pi, handlers, commands } = fakePi();
1130
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1131
+ const { ctx, ui } = fakeContext();
1132
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1133
+ let aborted = 0;
1134
+ editor.onEscape = () => { aborted++; };
1135
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
1136
+ editor.handleInput("\x1b");
1137
+ assert.equal(aborted, 1, "off by default: the first Esc still aborts");
1138
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1139
+ editor.handleInput("\x1b");
1140
+ assert.equal(aborted, 1, "now on: the same prompt instance must swallow the first Esc instead of aborting");
1141
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
1142
+ editor.dispose();
1143
+ });
1144
+
1145
+ test("gentle:double-esc-cancel re-syncs the keypress gate from the global file so status, toggle direction, and Esc behavior agree", async (t) => {
1146
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1147
+ const { pi, handlers, commands } = fakePi();
1148
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1149
+ const { ctx, ui } = fakeContext();
1150
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1151
+ let aborted = 0;
1152
+ editor.onEscape = () => { aborted++; };
1153
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
1154
+ // Another session (or a hand edit) turns the preference on underneath this one.
1155
+ mkdirSync(configHome, { recursive: true });
1156
+ writeFileSync(join(configHome, "double-esc-cancel.json"), JSON.stringify({ schema: "gentle-pi.double-esc-cancel/v1", policy: "on" }));
1157
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1158
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on \(decided by global file/);
1159
+ editor.handleInput("\x1b");
1160
+ assert.equal(aborted, 0, "status reported on, so the gate must swallow the first Esc rather than abort");
1161
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
1162
+ // The no-argument toggle flips relative to that same on-disk value: on -> off.
1163
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1164
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off /);
1165
+ assert.deepEqual(JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")), { schema: "gentle-pi.double-esc-cancel/v1", policy: "off" });
1166
+ editor.dispose();
1167
+ });
1168
+
443
1169
  test("gentleShell leaves an editor another extension already installed", () => {
444
1170
  const { pi, handlers } = fakePi();
445
1171
  gentleShell(pi, {});
@@ -508,6 +1234,15 @@ test("captured changes update the widget and bar without repository scans", asyn
508
1234
  await fire(handlers,"session_shutdown",ctx);
509
1235
  });
510
1236
 
1237
+
1238
+ // The changes overlay may ask Git for each captured root's HEAD label, and
1239
+ // nothing else: no status, diff, numstat or worktree scan behind the user's
1240
+ // back. Labelling is metadata, scanning is the behaviour these tests forbid.
1241
+ const onlyHeadLabels = (git: readonly string[][]) => git.every((args) => {
1242
+ const command = args[0] === "-C" ? args.slice(2) : args;
1243
+ return command[0] === "symbolic-ref" || (command[0] === "rev-parse" && command.includes("--verify"));
1244
+ });
1245
+
511
1246
  test("Changes opens only for captured mutations, not registered dirty roots", async () => {
512
1247
  const {pi,handlers,commands,tools,git}=fakePi();
513
1248
  gentleShell(pi,{});
@@ -522,7 +1257,7 @@ test("Changes opens only for captured mutations, not registered dirty roots", as
522
1257
  const opened=commands.get("gentle:changes")!.handler("",ctx);
523
1258
  await overlayReady;
524
1259
  assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
525
- assert.equal(git.length,0);
1260
+ assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
526
1261
  ui.closeOverlay?.(); await opened;
527
1262
  await fire(handlers,"session_shutdown",ctx);
528
1263
  });
@@ -545,7 +1280,7 @@ test("overlay groups captured roots and refreshes same-count diffs without HEAD
545
1280
  await new Promise(resolve=>setTimeout(resolve,20));
546
1281
  assert.match(ui.overlayView!.render(140).join("\n"),/second/);
547
1282
  assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
548
- assert.equal(git.length,0);
1283
+ assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
549
1284
  } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
550
1285
  });
551
1286
 
@@ -715,9 +1450,9 @@ const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { c
715
1450
  const USAGE_PAYLOAD = { plan_type: "pro", rate_limit: { primary_window: { used_percent: 40, limit_window_seconds: 604_800, reset_at: 1_788_777_491 } } };
716
1451
 
717
1452
  function fakeFetch(payload: unknown = USAGE_PAYLOAD, ok = true) {
718
- const calls: Array<{ url: string; headers: Record<string, string> }> = [];
1453
+ const calls: Array<{ url: string; headers: Record<string, string>; init: RequestInit }> = [];
719
1454
  const fetchFn = (async (url: string | URL, init?: RequestInit) => {
720
- calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string> });
1455
+ calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record<string, string>, init: init ?? {} });
721
1456
  return { ok, json: async () => payload } as Response;
722
1457
  }) as typeof fetch;
723
1458
  return { fetchFn, calls };
@@ -738,6 +1473,94 @@ test("fetchCodexUsage sends the token and account id and parses the payload", as
738
1473
  assert.equal(await fetchCodexUsage(undefined, plain.fetchFn, 0), undefined);
739
1474
  });
740
1475
 
1476
+ const NAN_QUOTA_PAYLOAD = {
1477
+ periodEnd: "2026-10-01T00:00:00.000Z",
1478
+ models: [
1479
+ {
1480
+ model: "glm5.3",
1481
+ cap: 3_000_000_000,
1482
+ fullCap: 3_000_000_000,
1483
+ tokensUsed: 820_000_000,
1484
+ windowHours: 4,
1485
+ windowTokens: 400_000_000,
1486
+ windowTokensUsed: 120_000_000,
1487
+ windowResetsAt: 1_788_620_161,
1488
+ },
1489
+ ],
1490
+ };
1491
+
1492
+ test("fetchNanUsage sends the key to the fixed quota origin and never follows a redirect", async () => {
1493
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
1494
+ const usage = await fetchNanUsage("sk-nan-secret", fetchFn, 1_788_600_000_000);
1495
+ assert.equal(usage?.provider, "nan");
1496
+ assert.equal(usage?.limits[0].name, "glm5.3");
1497
+ assert.equal(calls.length, 1);
1498
+ assert.equal(calls[0].url, "https://cloud-api.nan.builders/api/usage/quota");
1499
+ assert.equal(calls[0].headers.Authorization, "Bearer sk-nan-secret");
1500
+ assert.equal(calls[0].init.redirect, "error", "a redirect would forward the bearer to another origin");
1501
+ assert.equal(calls[0].init.cache, "no-store");
1502
+ assert.equal(JSON.stringify(usage).includes("sk-nan-secret"), false);
1503
+ });
1504
+
1505
+ test("fetchNanUsage degrades to no snapshot without ever throwing", async () => {
1506
+ const noKey = fakeFetch(NAN_QUOTA_PAYLOAD);
1507
+ assert.equal(await fetchNanUsage(undefined, noKey.fetchFn, 0), undefined);
1508
+ assert.equal(await fetchNanUsage("", noKey.fetchFn, 0), undefined);
1509
+ assert.equal(noKey.calls.length, 0, "no key, no request");
1510
+
1511
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch(NAN_QUOTA_PAYLOAD, false).fetchFn, 0), undefined, "a non-OK response is not a snapshot");
1512
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch({ models: [] }).fetchFn, 0), undefined, "an empty quota is not a snapshot");
1513
+ assert.equal(await fetchNanUsage("sk-nan-secret", fakeFetch({ models: [{ model: "glm5.3", cap: 0, tokensUsed: 0 }] }).fetchFn, 0), undefined);
1514
+ const refused = (async () => {
1515
+ throw new TypeError("redirect mode is not supported");
1516
+ }) as unknown as typeof fetch;
1517
+ assert.equal(await fetchNanUsage("sk-nan-secret", refused, 0), undefined, "a refused redirect degrades silently");
1518
+ });
1519
+
1520
+ test("gentleShell fetches NaN quota on session start and shows it in the bar", async () => {
1521
+ const { pi, handlers } = fakePi();
1522
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
1523
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
1524
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
1525
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1526
+ await fire(handlers, "session_start", ctx);
1527
+ await new Promise((resolve) => setTimeout(resolve, 0));
1528
+ assert.equal(calls.length, 1);
1529
+ assert.equal(calls[0].url, "https://cloud-api.nan.builders/api/usage/quota");
1530
+ // The fixture's session model holds no NaN allowance of its own, so the
1531
+ // account names the meter: a NaN payload of one model is still per-model data.
1532
+ assert.match(renderFooter(ui), /\$0\.000 sub ⟡ nan total ▰+▱+ 27%/);
1533
+
1534
+ await fire(handlers, "agent_end", ctx);
1535
+ await new Promise((resolve) => setTimeout(resolve, 0));
1536
+ assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
1537
+ });
1538
+
1539
+ test("a failed NaN refresh keeps the last valid snapshot", async () => {
1540
+ const { pi, handlers } = fakePi();
1541
+ const calls: string[] = [];
1542
+ let fail = false;
1543
+ const fetchFn = (async (url: string | URL) => {
1544
+ calls.push(String(url));
1545
+ if (fail) throw new TypeError("network down");
1546
+ return { ok: true, json: async () => NAN_QUOTA_PAYLOAD } as Response;
1547
+ }) as typeof fetch;
1548
+ let now = 1_788_600_000_000;
1549
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => now });
1550
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
1551
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1552
+ await fire(handlers, "session_start", ctx);
1553
+ await new Promise((resolve) => setTimeout(resolve, 0));
1554
+ assert.match(renderFooter(ui), /nan total ▰+▱+/);
1555
+
1556
+ fail = true;
1557
+ now += 6 * 60_000;
1558
+ await fire(handlers, "agent_end", ctx);
1559
+ await new Promise((resolve) => setTimeout(resolve, 0));
1560
+ assert.equal(calls.length, 2, "the refresh window elapsed, so the retry was attempted");
1561
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "a failed refresh cannot erase the last valid snapshot");
1562
+ });
1563
+
741
1564
  test("gentleShell fetches Codex usage on session start and shows it in the bar", async () => {
742
1565
  const { pi, handlers } = fakePi();
743
1566
  const { fetchFn, calls } = fakeFetch();
@@ -753,6 +1576,24 @@ test("gentleShell fetches Codex usage on session start and shows it in the bar",
753
1576
  assert.equal(calls.length, 1, "agent_end must not refetch within the refresh window");
754
1577
  });
755
1578
 
1579
+ test("a provider switch refreshes the new provider inside the same window", async () => {
1580
+ const { pi, handlers } = fakePi();
1581
+ const { fetchFn, calls } = fakeFetch(NAN_QUOTA_PAYLOAD);
1582
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
1583
+ const { ctx } = fakeContext({ token: JWT });
1584
+ await fire(handlers, "session_start", ctx);
1585
+ await new Promise((resolve) => setTimeout(resolve, 0));
1586
+ assert.equal(calls.length, 1);
1587
+
1588
+ // The 5-minute rule is per provider: the timestamp one provider set cannot
1589
+ // leave the next one waiting for a fetch it never made.
1590
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1591
+ await fire(handlers, "agent_end", ctx);
1592
+ await new Promise((resolve) => setTimeout(resolve, 0));
1593
+ assert.equal(calls.length, 2, "a provider switch is a reason to fetch, not to wait");
1594
+ assert.equal(calls[1].url, "https://cloud-api.nan.builders/api/usage/quota");
1595
+ });
1596
+
756
1597
  test("gentleShell records SSE rate-limit headers from provider responses", async () => {
757
1598
  const { pi, handlers } = fakePi();
758
1599
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
@@ -785,6 +1626,32 @@ test("gentleShell registers /gentle:usage and opens the subscriptions overlay",
785
1626
  await opened;
786
1627
  });
787
1628
 
1629
+ test("usageShortcut defaults to alt+u and can be overridden or disabled", () => {
1630
+ assert.equal(usageShortcut({}), "alt+u");
1631
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "ctrl+shift+u" }), "ctrl+shift+u");
1632
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "off" }), undefined);
1633
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "" }), undefined);
1634
+ });
1635
+
1636
+ test("gentleShell binds the usage shortcut to the same handler as /gentle:usage", async () => {
1637
+ const { pi, handlers, shortcuts } = fakePi();
1638
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
1639
+ const { ctx, ui } = fakeContext({ token: JWT });
1640
+ await fire(handlers, "session_start", ctx);
1641
+ const shortcut = shortcuts.get("alt+u");
1642
+ assert.ok(shortcut, "alt+u not registered");
1643
+ const opened = shortcut.handler(ctx);
1644
+ await new Promise((resolve) => setTimeout(resolve, 0));
1645
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1646
+ assert.match(plain[0], /Subscriptions/);
1647
+ ui.closeOverlay?.();
1648
+ await opened;
1649
+
1650
+ const silent = fakePi();
1651
+ gentleShell(silent.pi, { GENTLE_PI_SHELL_USAGE_KEY: "off" });
1652
+ assert.equal(silent.shortcuts.has("alt+u"), false, "the usage shortcut must not register when disabled");
1653
+ });
1654
+
788
1655
  test("gentleShell draws the review preflight message as a Gentle card", () => {
789
1656
  const { pi } = fakePi();
790
1657
  gentleShell(pi, {});
@@ -803,6 +1670,17 @@ test("gentleShell draws the review preflight message as a Gentle card", () => {
803
1670
  assert.equal(collapsed.length, 3);
804
1671
  });
805
1672
 
1673
+ test("the review preflight card paints the rose INFO frame (border) and title (accent)", () => {
1674
+ const { pi } = fakePi();
1675
+ gentleShell(pi, {});
1676
+ const renderer = renderers.get("gentle-pi.review-preflight")!;
1677
+ const taggedTheme = { ...plainTheme, fg: (color: string, text: string) => `<${color}>${text}</${color}>` };
1678
+ const message = { customType: "gentle-pi.review-preflight", content: "Receipt-driven development is enabled." };
1679
+ const lines = renderer(message, { expanded: true }, taggedTheme).render(60);
1680
+ assert.match(lines[0]!, /^<border>╭<\/border>/);
1681
+ assert.match(lines[0]!, /<accent>✿ Gentle AI<\/accent>/);
1682
+ });
1683
+
806
1684
  test("gentleShell keeps a dev-binary override visible above the editor for the whole session", async () => {
807
1685
  const { pi, handlers } = fakePi();
808
1686
  const deps = { fetch: fakeFetch({}, false).fetchFn, now: () => 0, devBinary: () => ({ state: "active" as const, path: "/Users/me/go/bin/gentle-ai", sha256: "6e53bfc6305a3949deadbeef" }) };