gentle-pi 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,12 +1,13 @@
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, fetchNanUsage, 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
+ import { USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
10
11
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
12
  import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
13
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
@@ -53,6 +54,7 @@ interface GitScript {
53
54
  }
54
55
 
55
56
  interface CommandRegistration {
57
+ description?: string;
56
58
  handler: (args: string, ctx: ExtensionContext) => Promise<void>;
57
59
  }
58
60
 
@@ -128,7 +130,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
128
130
  for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
129
131
  }
130
132
 
131
- function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
133
+ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise<string | undefined> } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
132
134
  const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined };
133
135
  let resolveOverlay: () => void;
134
136
  const overlayReady = new Promise<void>((resolve) => { resolveOverlay = resolve; });
@@ -136,6 +138,7 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
136
138
  const ctx = {
137
139
  hasUI: options.hasUI ?? true,
138
140
  hasPendingMessages: () => options.pending ?? false,
141
+ isIdle: () => options.idle ?? true,
139
142
  cwd: "/repo",
140
143
  model: { id: "gpt-5.5", provider: "openai-codex", reasoning: true, contextWindow: 272_000 },
141
144
  sessionManager: {
@@ -148,6 +151,9 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
148
151
  getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }),
149
152
  ui: {
150
153
  theme: plainTheme,
154
+ // Added only when requested: an absent select keeps the no-menu fallback
155
+ // that every pre-existing test relies on.
156
+ ...(options.select ? { select: options.select } : {}),
151
157
  setFooter(factory: unknown) {
152
158
  ui.footerFactory = factory;
153
159
  },
@@ -253,12 +259,11 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
253
259
  assert.match(lines[0], /main ⟡ gpt-5\.5 · medium/);
254
260
  });
255
261
 
256
- test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
262
+ test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => {
257
263
  const { pi, handlers } = fakePi();
258
264
  let profile: string | undefined = "team";
259
265
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
260
- const entries: unknown[] = [];
261
- const { ctx, ui } = fakeContext({ entries });
266
+ const { ctx, ui } = fakeContext();
262
267
  await fire(handlers, "session_start", ctx);
263
268
 
264
269
  const statuses = new Map<string, string>();
@@ -270,7 +275,7 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
270
275
  const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail;
271
276
  const live = () => rail.digest?.();
272
277
  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/);
278
+ assert.doesNotMatch(rail.render(46).join("\n"), /gpt-5\.5/, "model now lives in the header, not the Status card");
274
279
 
275
280
  assert.match(rail.render(46).join("\n"), /Profile.*team/);
276
281
  const beforeProfile = live();
@@ -280,26 +285,79 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
280
285
  profile = undefined;
281
286
  assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
282
287
 
288
+ const beforeStatus = live();
289
+ statuses.set("mcp", "MCP: 3 servers enabled");
290
+ assert.notEqual(live(), beforeStatus, "extension statuses have no event and must change the digest");
291
+ assert.match(rail.render(46).join("\n"), /MCP: 3 servers enabled/);
292
+ assert.equal(live(), live(), "an unchanged digest still reuses the prepared rail");
293
+ } finally {
294
+ component.dispose();
295
+ }
296
+ });
297
+
298
+ test("the fullscreen header rail carries a live digest so model, context, and cost changes refresh it", async () => {
299
+ const { pi, handlers } = fakePi();
300
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
301
+ const entries: unknown[] = [];
302
+ const { ctx, ui } = fakeContext({ entries });
303
+ await fire(handlers, "session_start", ctx);
304
+
305
+ const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map([["mcp", "MCP: 3 servers enabled"]]), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
306
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
307
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
308
+ const component = factory(tui, plainTheme, liveFooterData);
309
+ try {
310
+ const header = sidebarState(tui as unknown as TUI).parts.get("header") as SidebarRail;
311
+ assert.equal(typeof header.digest, "function", "the header paints live state every frame and must declare a digest");
312
+ const live = () => header.digest?.();
313
+ const text = () => header.render(160).join("\n");
314
+ assert.match(text(), /gpt-5\.5/);
315
+ assert.doesNotMatch(text(), /MCP: 3 servers/, "extension statuses never reach the header");
316
+ assert.doesNotMatch(text(), /working/i, "the working state never reaches the header");
317
+
283
318
  const beforeModel = live();
284
319
  (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/);
320
+ assert.notEqual(live(), beforeModel, "/model must change the header digest");
321
+ assert.match(text(), /gpt-5\.6/);
287
322
 
288
323
  const beforeUsage = live();
289
324
  (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%/);
325
+ assert.notEqual(live(), beforeUsage, "context usage must change the header digest");
326
+ assert.match(text(), /74%/);
292
327
 
293
328
  const beforeCost = live();
294
329
  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/);
330
+ assert.notEqual(live(), beforeCost, "session cost must change the header digest");
331
+ assert.match(text(), /\$0\.420/);
332
+ assert.equal(live(), live(), "an unchanged digest still reuses the prepared header");
333
+ } finally {
334
+ component.dispose();
335
+ }
336
+ });
297
337
 
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");
338
+ test("clicking the header's usage segment opens the usage panel; other header clicks are ignored", async () => {
339
+ const { pi, handlers } = fakePi();
340
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
341
+ const { ctx, ui } = fakeContext();
342
+ await fire(handlers, "session_start", ctx);
343
+
344
+ const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
345
+ const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
346
+ const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void };
347
+ const component = factory(tui, plainTheme, liveFooterData);
348
+ try {
349
+ const header = sidebarState(tui as unknown as TUI).parts.get("header") as SidebarRail;
350
+ const line = header.render(160).join("");
351
+ const usageAt = line.indexOf("usage");
352
+ assert.ok(usageAt >= 0, "the header shows a standing usage segment");
353
+
354
+ 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);
355
+ assert.equal(click(0), undefined, "a click on the brand does not open the panel");
356
+ const hit = click(usageAt + 1);
357
+ assert.equal(hit?.handled, true);
358
+ await new Promise((resolve) => setTimeout(resolve, 0));
359
+ assert.match(ui.overlayView!.render(90).join("\n"), /Subscriptions/);
360
+ ui.closeOverlay?.();
303
361
  } finally {
304
362
  component.dispose();
305
363
  }
@@ -355,10 +413,15 @@ const fakeTui = { terminal: { rows: 40, columns: 120 }, requestRender() {} };
355
413
  const editorTheme = { borderColor: (text: string) => text, selectList: {} };
356
414
  const fakeKeybindings = { matches: () => false };
357
415
 
358
- function installedPrompt(ctx: ExtensionContext, ui: FakeUi, handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>): GentlePromptEditor {
416
+ function installedPrompt(
417
+ ctx: ExtensionContext,
418
+ ui: FakeUi,
419
+ handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>,
420
+ keybindings: unknown = fakeKeybindings,
421
+ ): GentlePromptEditor {
359
422
  for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
360
423
  const factory = ui.editorFactory as (tui: unknown, theme: unknown, keybindings: unknown) => GentlePromptEditor;
361
- return factory(fakeTui, editorTheme, fakeKeybindings);
424
+ return factory(fakeTui, editorTheme, keybindings);
362
425
  }
363
426
 
364
427
  test("gentleShell frames the editor with the petal prompt and a hint while empty", () => {
@@ -416,7 +479,122 @@ test("gentleShell shows working while the agent runs and queued when messages wa
416
479
  editor.dispose();
417
480
  });
418
481
 
482
+ test("animations command reports without writing and switches the live pulse", async (t) => {
483
+ const configHome = scopedDoubleEscCancelConfigHome(t);
484
+ const delays: number[] = [];
485
+ let active = 0;
486
+ t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
487
+ delays.push(delay); active++; return { unref() {} };
488
+ });
489
+ t.mock.method(globalThis, "clearInterval", () => { active--; });
490
+ const { pi, handlers, commands } = fakePi();
491
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
492
+ const { ctx, ui } = fakeContext();
493
+ const editor = installedPrompt(ctx, ui, handlers);
494
+ const command = commands.get("gentle:animations");
495
+ assert.ok(command);
496
+ await command.handler("", ctx);
497
+ assert.match(ui.notices.at(-1)!, /animations: quality/);
498
+ assert.equal(existsSync(join(configHome, "animations.json")), false);
499
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
500
+ assert.deepEqual(delays, [80]);
501
+ await command.handler("performance", ctx);
502
+ assert.deepEqual(delays, [80, 1000]);
503
+ assert.equal(active, 1);
504
+ await command.handler("potato", ctx);
505
+ assert.equal(active, 0);
506
+ assert.match(stripAnsi(editor.render(60)[0]), /working/);
507
+ assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
508
+ await command.handler("invalid", ctx);
509
+ assert.equal(JSON.parse(readFileSync(join(configHome, "animations.json"), "utf8")).policy, "potato");
510
+ await command.handler("quality", ctx);
511
+ assert.deepEqual(delays, [80, 1000, 80]);
512
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
513
+ assert.equal(active, 0);
514
+ editor.dispose();
515
+ });
516
+
517
+ test("potato repaints start/settle and shows queued state on the host's next render without intervals", async (t) => {
518
+ const configHome = scopedDoubleEscCancelConfigHome(t);
519
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"potato"}');
520
+ const intervals = t.mock.method(globalThis, "setInterval", () => { throw new Error("potato must not animate"); });
521
+ const renders = t.mock.method(fakeTui, "requestRender", () => {});
522
+ const { pi, handlers, commands } = fakePi();
523
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
524
+ const { ctx, ui } = fakeContext();
525
+ const pending = { value: false };
526
+ (ctx as unknown as { hasPendingMessages(): boolean }).hasPendingMessages = () => pending.value;
527
+ const editor = installedPrompt(ctx, ui, handlers);
528
+ let before = renders.mock.callCount();
529
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
530
+ assert.ok(renders.mock.callCount() > before);
531
+ assert.match(stripAnsi(editor.render(60)[0]), /✿ working/);
532
+ before = renders.mock.callCount();
533
+ pending.value = true;
534
+ assert.equal(renders.mock.callCount(), before, "changing the queue flag is not a Gentle repaint event");
535
+ // Pi owns enqueue and its repaint scheduling; this fake context exposes only
536
+ // hasPendingMessages, not Pi's enqueue path. Simulate the host's render request:
537
+ // this proves next-render visibility, not real Pi enqueue-to-paint latency.
538
+ fakeTui.requestRender();
539
+ assert.equal(renders.mock.callCount(), before + 1);
540
+ assert.match(stripAnsi(editor.render(60)[0]), /✿ queued/);
541
+ before = renders.mock.callCount();
542
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
543
+ assert.ok(renders.mock.callCount() > before);
544
+ assert.doesNotMatch(stripAnsi(editor.render(60)[0]), /working|queued/);
545
+ await commands.get("gentle:animations")!.handler("status", ctx);
546
+ assert.match(ui.notices.at(-1)!, /animations: potato/);
547
+ for (const handler of handlers.get("session_shutdown") ?? []) handler({}, ctx);
548
+ assert.equal(intervals.mock.callCount(), 0);
549
+ });
550
+
551
+ test("animations status attributes malformed files and reports a failed write", async (t) => {
552
+ const configHome = scopedDoubleEscCancelConfigHome(t);
553
+ const path = join(configHome, "animations.json");
554
+ writeFileSync(path, "broken");
555
+ const { pi, commands } = fakePi();
556
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
557
+ const { ctx, ui } = fakeContext();
558
+ await commands.get("gentle:animations")!.handler("", ctx);
559
+ assert.match(ui.notices.at(-1)!, /animations: quality.*global file.*malformed/);
560
+ assert.equal(readFileSync(path, "utf8"), "broken");
561
+ rmSync(path);
562
+ mkdirSync(path);
563
+ await commands.get("gentle:animations")!.handler("potato", ctx);
564
+ assert.match(ui.notices.at(-1)!, /EISDIR|ENOTEMPTY|EPERM/);
565
+ });
566
+
567
+ test("animations with no argument opens a selectable menu and applies the chosen policy", async (t) => {
568
+ const configHome = scopedDoubleEscCancelConfigHome(t);
569
+ const path = join(configHome, "animations.json");
570
+ const { pi, commands } = fakePi();
571
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
572
+ // No editor/prompt is installed: the handler's `prompt?.setAnimationPolicy`
573
+ // optional chain must tolerate the interactive menu without one.
574
+ const chosen = fakeContext({
575
+ select: async (title, options) => {
576
+ assert.match(title, /Gentle animations/);
577
+ assert.deepEqual(options, ["quality", "performance", "potato", "status"]);
578
+ return "potato";
579
+ },
580
+ });
581
+ await commands.get("gentle:animations")!.handler("", chosen.ctx);
582
+ assert.equal(JSON.parse(readFileSync(path, "utf8")).policy, "potato");
583
+ assert.match(chosen.ui.notices.at(-1)!, /animations: potato/);
584
+
585
+ // A dismissed menu (undefined selection) reports nothing and writes nothing.
586
+ const dismissHome = scopedDoubleEscCancelConfigHome(t);
587
+ const { pi: dismissPi, commands: dismissCommands } = fakePi();
588
+ gentleShell(dismissPi, { GENTLE_PI_CONFIG_HOME: dismissHome });
589
+ const dismissed = fakeContext({ select: async () => undefined });
590
+ await dismissCommands.get("gentle:animations")!.handler("", dismissed.ctx);
591
+ assert.equal(dismissed.ui.notices.length, 0);
592
+ assert.equal(existsSync(join(dismissHome, "animations.json")), false);
593
+ });
594
+
419
595
  test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
596
+ const configHome = scopedDoubleEscCancelConfigHome(t);
597
+ writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');
420
598
  const delays: number[] = [];
421
599
  let active = 0;
422
600
  let unrefs = 0;
@@ -427,7 +605,7 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
427
605
  });
428
606
  t.mock.method(globalThis, "clearInterval", () => { active--; });
429
607
  const { pi, handlers } = fakePi();
430
- gentleShell(pi, {});
608
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
431
609
  const { ctx, ui } = fakeContext();
432
610
  const editor = installedPrompt(ctx, ui, handlers);
433
611
  assert.equal(active, 0);
@@ -440,6 +618,673 @@ test("prompt uses the compact banner cadence and releases its unref timer at set
440
618
  assert.equal(active, 0);
441
619
  });
442
620
 
621
+ // ---------------------------------------------------------------------------
622
+ // double-esc-cancel (issue #1163): opt-in, off by default. While the prompt
623
+ // is working and autocomplete is hidden, the first Esc is swallowed and the
624
+ // frame shows a hint; a second Esc within the window falls through to
625
+ // CustomEditor's own handleInput so Pi's onEscape performs the abort exactly
626
+ // as it always has. Idle double-Esc (tree/fork), bash-mode Esc, and
627
+ // autocomplete cancel live entirely in Pi's own onEscape/CustomEditor and are
628
+ // untouched by this gate.
629
+ // ---------------------------------------------------------------------------
630
+
631
+ const escapeKeybindings = { matches: (_data: string, keybinding: string) => keybinding === "app.interrupt" };
632
+
633
+ test("double-esc-cancel default off: a single Esc while working still aborts immediately, exactly as before", (t) => {
634
+ const { pi, handlers } = fakePi();
635
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
636
+ const { ctx, ui } = fakeContext();
637
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
638
+ let aborted = 0;
639
+ editor.onEscape = () => { aborted++; };
640
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
641
+ editor.handleInput("\x1b");
642
+ assert.equal(aborted, 1);
643
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
644
+ editor.dispose();
645
+ });
646
+
647
+ function scopedDoubleEscCancelConfigHome(t: { after(callback: () => void): void }): string {
648
+ const configHome = mkdtempSync(join(tmpdir(), "gp-esc-cfg-"));
649
+ t.after(() => rmSync(configHome, { recursive: true, force: true }));
650
+ return configHome;
651
+ }
652
+
653
+ test("double-esc-cancel enabled: the first Esc while working is swallowed and shows the hint instead of aborting", (t) => {
654
+ const configHome = scopedDoubleEscCancelConfigHome(t);
655
+ const { pi, handlers } = fakePi();
656
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
657
+ const { ctx, ui } = fakeContext();
658
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
659
+ let aborted = 0;
660
+ editor.onEscape = () => { aborted++; };
661
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
662
+ editor.handleInput("\x1b");
663
+ assert.equal(aborted, 0, "the first Esc must be swallowed, not aborted");
664
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
665
+ editor.dispose();
666
+ });
667
+
668
+ test("double-esc-cancel enabled: a second Esc within the window falls through and aborts, clearing the hint", (t) => {
669
+ let now = 1_000_000;
670
+ const configHome = scopedDoubleEscCancelConfigHome(t);
671
+ const { pi, handlers } = fakePi();
672
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
673
+ const { ctx, ui } = fakeContext();
674
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
675
+ let aborted = 0;
676
+ editor.onEscape = () => { aborted++; };
677
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
678
+ editor.handleInput("\x1b");
679
+ assert.equal(aborted, 0);
680
+ now += 500;
681
+ editor.handleInput("\x1b");
682
+ assert.equal(aborted, 1, "the second Esc within the window must fall through to abort");
683
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
684
+ editor.dispose();
685
+ });
686
+
687
+ test("double-esc-cancel enabled: an Esc after the window expires is a fresh first press, not an abort", (t) => {
688
+ let now = 1_000_000;
689
+ const configHome = scopedDoubleEscCancelConfigHome(t);
690
+ const { pi, handlers } = fakePi();
691
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
692
+ const { ctx, ui } = fakeContext();
693
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
694
+ let aborted = 0;
695
+ editor.onEscape = () => { aborted++; };
696
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
697
+ editor.handleInput("\x1b");
698
+ assert.equal(aborted, 0);
699
+ now += 1001;
700
+ editor.handleInput("\x1b");
701
+ assert.equal(aborted, 0, "the window expired, so this must be treated as a new first press");
702
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
703
+ editor.dispose();
704
+ });
705
+
706
+ test("double-esc-cancel enabled: Esc while idle passes straight through, untouched by this gate", (t) => {
707
+ const configHome = scopedDoubleEscCancelConfigHome(t);
708
+ const { pi, handlers } = fakePi();
709
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
710
+ const { ctx, ui } = fakeContext();
711
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
712
+ let aborted = 0;
713
+ editor.onEscape = () => { aborted++; };
714
+ // agent_start never fired: the prompt stays idle.
715
+ editor.handleInput("\x1b");
716
+ assert.equal(aborted, 1, "idle Esc must be unaffected by the policy");
717
+ editor.dispose();
718
+ });
719
+
720
+ test("double-esc-cancel enabled: Esc while autocomplete is visible bypasses this gate entirely", (t) => {
721
+ const configHome = scopedDoubleEscCancelConfigHome(t);
722
+ const { pi, handlers } = fakePi();
723
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome });
724
+ const { ctx, ui } = fakeContext();
725
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
726
+ (editor as unknown as { isShowingAutocomplete(): boolean }).isShowingAutocomplete = () => true;
727
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
728
+ editor.handleInput("\x1b");
729
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/, "autocomplete must bypass the gate, matching CustomEditor's own guard");
730
+ editor.dispose();
731
+ });
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // Working-cancel keeps the queue moving (issue #1218). Pi's own onEscape
735
+ // restores `[queuedText, currentText].filter(t => t.trim()).join("\n\n")`
736
+ // into the editor and aborts. These tests fake that join in `onEscape` (the
737
+ // same seam the double-esc-cancel tests above use), then check that the
738
+ // user's own draft is all that is left in the editor and that the queued
739
+ // text is sent exactly once, after the aborted run settles.
740
+ // ---------------------------------------------------------------------------
741
+
742
+ test("extractQueuedText reverses Pi's queued+draft join", () => {
743
+ assert.equal(extractQueuedText("follow up\n\ndraft reply", "draft reply"), "follow up");
744
+ assert.equal(extractQueuedText("draft reply", "draft reply"), "", "no queue: the join degenerates to the draft alone");
745
+ assert.equal(extractQueuedText("only queued", ""), "only queued", "empty draft: the join degenerates to the queue alone");
746
+ assert.equal(extractQueuedText("", ""), "", "both empty");
747
+ 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");
748
+ });
749
+
750
+ test("extractQueuedText treats a whitespace-only draft as empty, matching Pi's own trim filter", () => {
751
+ assert.equal(extractQueuedText("only queued", " "), "only queued", "a whitespace-only draft never survives Pi's filter, so the whole join is the queue");
752
+ assert.equal(extractQueuedText("", " "), "", "whitespace-only draft with no queue is still no queue, not unrecognized");
753
+ });
754
+
755
+ test("working cancel: an aborted turn with a queued message sends it once settled and keeps the draft", (t) => {
756
+ const { pi, handlers, sentMessages } = fakePi();
757
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
758
+ const { ctx, ui } = fakeContext();
759
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
760
+ editor.setText("draft reply");
761
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
762
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
763
+ editor.handleInput("\x1b");
764
+ assert.equal(editor.getText(), "draft reply", "the user's own draft stays in the editor");
765
+ assert.equal(sentMessages.length, 0, "nothing is sent until the aborted run settles");
766
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
767
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
768
+ editor.dispose();
769
+ });
770
+
771
+ test("working cancel: an aborted turn with no queued messages behaves exactly as before, with no redundant setText", (t) => {
772
+ const { pi, handlers, sentMessages } = fakePi();
773
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
774
+ const { ctx, ui } = fakeContext();
775
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
776
+ editor.setText("draft reply");
777
+ // Pi's own onEscape restore is the one real setText call on this path
778
+ // (it always writes the combined text, even when that equals the
779
+ // draft); the spy below counts every call, so it must see only that one
780
+ // and none added by abortAndDispatchQueued itself.
781
+ editor.onEscape = () => { editor.setText(editor.getText()); };
782
+ let setTextCalls = 0;
783
+ const originalSetText = editor.setText.bind(editor);
784
+ (editor as unknown as { setText(text: string): void }).setText = (text: string) => { setTextCalls++; originalSetText(text); };
785
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
786
+ editor.handleInput("\x1b");
787
+ assert.equal(setTextCalls, 1, "the no-queue path must not add its own write on top of Pi's own restore");
788
+ assert.equal(editor.getText(), "draft reply");
789
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
790
+ assert.equal(sentMessages.length, 0, "an empty queue must never trigger a send");
791
+ editor.dispose();
792
+ });
793
+
794
+ test("working cancel: an unrecognized restore shape leaves Pi's own text untouched and dispatches nothing", (t) => {
795
+ const { pi, handlers, sentMessages } = fakePi();
796
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
797
+ const { ctx, ui } = fakeContext();
798
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
799
+ editor.setText("draft reply");
800
+ // Simulates a future Pi restore shape this code does not recognize.
801
+ editor.onEscape = () => { editor.setText("an unrecognized shape"); };
802
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
803
+ editor.handleInput("\x1b");
804
+ assert.equal(editor.getText(), "an unrecognized shape", "Pi's own restore wins on a shape mismatch; nothing here overwrites it");
805
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
806
+ assert.equal(sentMessages.length, 0, "an unrecognized shape must never be dispatched as if it were queued text");
807
+ editor.dispose();
808
+ });
809
+
810
+ test("working cancel: an empty draft with a queued message sends the whole queue and leaves the editor empty", (t) => {
811
+ const { pi, handlers, sentMessages } = fakePi();
812
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
813
+ const { ctx, ui } = fakeContext();
814
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
815
+ editor.onEscape = () => { editor.setText("only queued"); };
816
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
817
+ editor.handleInput("\x1b");
818
+ assert.equal(editor.getText(), "", "an empty draft stays empty");
819
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
820
+ assert.deepEqual(sentMessages.map((m) => m.content), ["only queued"]);
821
+ editor.dispose();
822
+ });
823
+
824
+ test("working cancel: the same dispatch applies to the confirming second Esc when double-esc-cancel is enabled", (t) => {
825
+ let now = 1_000_000;
826
+ const configHome = scopedDoubleEscCancelConfigHome(t);
827
+ const { pi, handlers, sentMessages } = fakePi();
828
+ gentleShell(pi, { GENTLE_PI_DOUBLE_ESC_CANCEL: "on", GENTLE_PI_CONFIG_HOME: configHome }, { now: () => now });
829
+ const { ctx, ui } = fakeContext();
830
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
831
+ editor.setText("draft reply");
832
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
833
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
834
+ editor.handleInput("\x1b");
835
+ now += 500;
836
+ editor.handleInput("\x1b");
837
+ assert.equal(editor.getText(), "draft reply");
838
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
839
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
840
+ editor.dispose();
841
+ });
842
+
843
+ test("working cancel: queued text is never sent twice even if agent_settled fires again", (t) => {
844
+ const { pi, handlers, sentMessages } = fakePi();
845
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
846
+ const { ctx, ui } = fakeContext();
847
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
848
+ editor.setText("draft reply");
849
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
850
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
851
+ editor.handleInput("\x1b");
852
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
853
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
854
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
855
+ editor.dispose();
856
+ });
857
+
858
+ test("working cancel: a new agent_start before the aborted run settles keeps the pending text and sends it once that turn settles", (t) => {
859
+ const { pi, handlers, sentMessages } = fakePi();
860
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
861
+ const { ctx, ui } = fakeContext();
862
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
863
+ editor.setText("draft reply");
864
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
865
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
866
+ editor.handleInput("\x1b");
867
+ // The user sent the draft (or another turn started) before the aborted
868
+ // run's own agent_settled fired. Nothing is sent from inside agent_start:
869
+ // Pi is mid-turn there, so the text waits for that turn to settle.
870
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
871
+ assert.equal(sentMessages.length, 0, "never send from inside agent_start");
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
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
875
+ assert.equal(sentMessages.length, 1, "the pending text must not be delivered a second time");
876
+ editor.dispose();
877
+ });
878
+
879
+ test("working cancel: a whitespace-only recognized queue is treated as no queue and never dispatched", (t) => {
880
+ const { pi, handlers, sentMessages } = fakePi();
881
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
882
+ const { ctx, ui } = fakeContext();
883
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
884
+ editor.setText("draft reply");
885
+ editor.onEscape = () => { editor.setText(` \n\n${editor.getText()}`); };
886
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
887
+ editor.handleInput("\x1b");
888
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
889
+ assert.equal(sentMessages.length, 0, "whitespace is not a message");
890
+ assert.equal(editor.getText(), "draft reply", "the recognized whitespace prefix is stripped and the draft comes back alone");
891
+ editor.dispose();
892
+ });
893
+
894
+ test("working cancel: a failing sendUserMessage on settle is reported, not thrown, and the prompt still leaves the working state", (t) => {
895
+ const { pi, handlers } = fakePi();
896
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
897
+ const { ctx, ui } = fakeContext();
898
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
899
+ editor.setText("draft reply");
900
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
901
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
902
+ editor.handleInput("\x1b");
903
+ (pi as unknown as { sendUserMessage: (content: string) => void }).sendUserMessage = () => { throw new Error("session is switching"); };
904
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
905
+ assert.ok(ui.notices.some((notice) => /session is switching/.test(notice)), "the failure surfaces as a notice");
906
+ 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");
907
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/, "the prompt is idle again");
908
+ editor.dispose();
909
+ });
910
+
911
+ test("working cancel: a failing send reported through a context without UI still restores the queued text into the editor", (t) => {
912
+ const { pi, handlers } = fakePi();
913
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
914
+ const { ctx, ui } = fakeContext();
915
+ const headless = fakeContext({ hasUI: false });
916
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
917
+ editor.setText("draft reply");
918
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
919
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
920
+ editor.handleInput("\x1b");
921
+ (pi as unknown as { sendUserMessage: (content: string) => void }).sendUserMessage = () => { throw new Error("session is switching"); };
922
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, headless.ctx);
923
+ assert.equal(headless.ui.notices.length, 0, "no UI, no notice");
924
+ assert.equal(editor.getText(), "follow up\n\ndraft reply", "with or without a UI to tell the user, the text is never dropped");
925
+ editor.dispose();
926
+ });
927
+
928
+ test("working cancel: agent_settled never sends while another turn is still in flight; the text waits for an idle settle", (t) => {
929
+ const { pi, handlers, sentMessages } = fakePi();
930
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
931
+ const { ctx, ui } = fakeContext();
932
+ const busy = fakeContext({ idle: false }).ctx;
933
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
934
+ editor.setText("draft reply");
935
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
936
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
937
+ editor.handleInput("\x1b");
938
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, busy);
939
+ assert.equal(sentMessages.length, 0, "a settle reported while not idle must not inject the text mid-turn");
940
+ let aborted = 0;
941
+ editor.onEscape = () => { aborted++; };
942
+ editor.handleInput("\x1b");
943
+ assert.equal(aborted, 1, "a run is still in flight, so the prompt stays working and Esc takes the cancel path");
944
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "not the idle clear");
945
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
946
+ assert.deepEqual(sentMessages.map((m) => m.content), ["follow up"]);
947
+ editor.dispose();
948
+ });
949
+
950
+ test("working cancel: two aborts before an idle settle deliver both queued texts, in order, as one message", (t) => {
951
+ const { pi, handlers, sentMessages } = fakePi();
952
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
953
+ const { ctx, ui } = fakeContext();
954
+ const busy = fakeContext({ idle: false }).ctx;
955
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
956
+ let aborts = 0;
957
+ editor.onEscape = () => { aborts++; editor.setText(aborts === 1 ? "first" : "second"); };
958
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
959
+ editor.handleInput("\x1b");
960
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, busy);
961
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
962
+ editor.handleInput("\x1b");
963
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
964
+ assert.deepEqual(sentMessages.map((m) => m.content), ["first\n\nsecond"], "nothing from the first abort is lost, and order is preserved");
965
+ editor.dispose();
966
+ });
967
+
968
+ test("working cancel: pending queued text never crosses a session boundary", (t) => {
969
+ const { pi, handlers, sentMessages } = fakePi();
970
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
971
+ const { ctx, ui } = fakeContext();
972
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
973
+ editor.setText("draft reply");
974
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
975
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
976
+ editor.handleInput("\x1b");
977
+ for (const handler of handlers.get("session_shutdown") ?? []) handler({}, ctx);
978
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
979
+ assert.equal(sentMessages.length, 0, "the old session's queued text must not be sent into the next session");
980
+ });
981
+
982
+ test("working cancel: agent_settled firing first delivers the pending text without a deliverAs override", (t) => {
983
+ const { pi, handlers, sentMessages } = fakePi();
984
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
985
+ const { ctx, ui } = fakeContext();
986
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
987
+ editor.setText("draft reply");
988
+ editor.onEscape = () => { editor.setText(`follow up\n\n${editor.getText()}`); };
989
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
990
+ editor.handleInput("\x1b");
991
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
992
+ assert.deepEqual(sentMessages.map((m) => ({ content: m.content, deliverAs: m.options?.deliverAs })), [{ content: "follow up", deliverAs: undefined }]);
993
+ // The next turn's own agent_start must not re-deliver the already-sent text.
994
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
995
+ assert.equal(sentMessages.length, 1);
996
+ editor.dispose();
997
+ });
998
+
999
+ // ---------------------------------------------------------------------------
1000
+ // Idle double-Esc clears the draft (issue #1218). With the prompt idle,
1001
+ // autocomplete hidden, and a non-empty draft, a first Esc shows a hint
1002
+ // instead of doing nothing; a second Esc within 500ms adds the draft to
1003
+ // history and clears it. An empty editor, autocomplete, or the working state
1004
+ // are all untouched: Pi's own idle double-Esc (tree/fork) and the
1005
+ // working-cancel gate above keep deciding those cases.
1006
+ // ---------------------------------------------------------------------------
1007
+
1008
+ test("idle draft: the first Esc shows the clear hint and keeps the text", (t) => {
1009
+ const { pi, handlers } = fakePi();
1010
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1011
+ const { ctx, ui } = fakeContext();
1012
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1013
+ editor.setText("draft reply");
1014
+ let aborted = 0;
1015
+ editor.onEscape = () => { aborted++; };
1016
+ editor.handleInput("\x1b");
1017
+ assert.equal(aborted, 0, "the first Esc must be swallowed, not passed to Pi's own idle handler");
1018
+ assert.equal(editor.getText(), "draft reply");
1019
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
1020
+ editor.dispose();
1021
+ });
1022
+
1023
+ test("idle draft: a second Esc within the window adds the draft to history and clears it", (t) => {
1024
+ let now = 1_000_000;
1025
+ const { pi, handlers } = fakePi();
1026
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
1027
+ const { ctx, ui } = fakeContext();
1028
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1029
+ editor.setText("draft reply");
1030
+ const history: string[] = [];
1031
+ (editor as unknown as { addToHistory(text: string): void }).addToHistory = (text: string) => history.push(text);
1032
+ editor.handleInput("\x1b");
1033
+ now += 400;
1034
+ editor.handleInput("\x1b");
1035
+ assert.deepEqual(history, ["draft reply"]);
1036
+ assert.equal(editor.getText(), "");
1037
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
1038
+ editor.dispose();
1039
+ });
1040
+
1041
+ test("idle draft: an Esc after the window expires is a fresh first press, not a clear", (t) => {
1042
+ let now = 1_000_000;
1043
+ const { pi, handlers } = fakePi();
1044
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
1045
+ const { ctx, ui } = fakeContext();
1046
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1047
+ editor.setText("draft reply");
1048
+ editor.handleInput("\x1b");
1049
+ now += 501;
1050
+ editor.handleInput("\x1b");
1051
+ assert.equal(editor.getText(), "draft reply", "the window expired, so this is a new first press, not a clear");
1052
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/);
1053
+ editor.dispose();
1054
+ });
1055
+
1056
+ test("idle empty editor: Esc passes straight through to Pi's own tree/fork double-Esc", (t) => {
1057
+ const { pi, handlers } = fakePi();
1058
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1059
+ const { ctx, ui } = fakeContext();
1060
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1061
+ let aborted = 0;
1062
+ editor.onEscape = () => { aborted++; };
1063
+ editor.handleInput("\x1b");
1064
+ assert.equal(aborted, 1, "an empty editor must be untouched by the idle-clear gate");
1065
+ editor.dispose();
1066
+ });
1067
+
1068
+ test("idle draft: Esc while autocomplete is visible bypasses the idle-clear gate", (t) => {
1069
+ const { pi, handlers } = fakePi();
1070
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1071
+ const { ctx, ui } = fakeContext();
1072
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1073
+ editor.setText("draft reply");
1074
+ (editor as unknown as { isShowingAutocomplete(): boolean }).isShowingAutocomplete = () => true;
1075
+ editor.handleInput("\x1b");
1076
+ assert.equal(editor.getText(), "draft reply", "autocomplete cancel must never clear the draft");
1077
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "autocomplete must bypass the idle-clear gate, matching CustomEditor's own guard");
1078
+ editor.dispose();
1079
+ });
1080
+
1081
+ test("working state: a non-empty draft's Esc is decided by the working-cancel gate, never the idle-clear hint", (t) => {
1082
+ const { pi, handlers } = fakePi();
1083
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1084
+ const { ctx, ui } = fakeContext();
1085
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1086
+ editor.setText("draft reply");
1087
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
1088
+ editor.onEscape = () => { editor.setText(editor.getText()); };
1089
+ editor.handleInput("\x1b");
1090
+ assert.doesNotMatch(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "working must never show the idle-clear hint");
1091
+ editor.dispose();
1092
+ });
1093
+
1094
+ test("idle draft: editing the text between two Esc presses starts a fresh clear window instead of clearing the edit away", (t) => {
1095
+ let now = 1_000_000;
1096
+ const { pi, handlers } = fakePi();
1097
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
1098
+ const { ctx, ui } = fakeContext();
1099
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1100
+ editor.setText("draft reply");
1101
+ editor.handleInput("\x1b");
1102
+ editor.setText("draft reply, edited");
1103
+ 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");
1104
+ now += 400;
1105
+ editor.handleInput("\x1b");
1106
+ assert.equal(editor.getText(), "draft reply, edited", "an edit invalidates the earlier snapshot, so this must not clear");
1107
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to clear/, "the edit starts a fresh first press, with its own hint");
1108
+ now += 400;
1109
+ editor.handleInput("\x1b");
1110
+ assert.equal(editor.getText(), "", "the fresh window's own second Esc, on the unchanged edited text, does clear");
1111
+ editor.dispose();
1112
+ });
1113
+
1114
+ test("idle draft: typing and deleting between two Esc presses still invalidates the pending clear", (t) => {
1115
+ let now = 1_000_000;
1116
+ const { pi, handlers } = fakePi();
1117
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) }, { now: () => now });
1118
+ const { ctx, ui } = fakeContext();
1119
+ // Esc-only matching: ordinary keystrokes must reach the editor as text.
1120
+ const preciseEscape = { matches: (data: string, keybinding: string) => keybinding === "app.interrupt" && data === "\x1b" };
1121
+ const editor = installedPrompt(ctx, ui, handlers, preciseEscape);
1122
+ editor.setText("draft reply");
1123
+ editor.handleInput("\x1b");
1124
+ editor.handleInput("x");
1125
+ editor.handleInput("\x7f");
1126
+ assert.equal(editor.getText(), "draft reply", "type then backspace lands on the same text");
1127
+ now += 400;
1128
+ editor.handleInput("\x1b");
1129
+ assert.equal(editor.getText(), "draft reply", "an intervening keystroke invalidates the confirmation even when the text ends up identical");
1130
+ editor.dispose();
1131
+ });
1132
+
1133
+ test("idle draft: a bash-mode draft's Esc bypasses the idle-clear gate entirely", (t) => {
1134
+ const { pi, handlers } = fakePi();
1135
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1136
+ const { ctx, ui } = fakeContext();
1137
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1138
+ editor.setText("!ls");
1139
+ let aborted = 0;
1140
+ editor.onEscape = () => { aborted++; };
1141
+ editor.handleInput("\x1b");
1142
+ assert.equal(aborted, 1, "a bash-mode draft's first Esc must reach Pi's own bash-mode onEscape, which clears bash mode");
1143
+ assert.equal(editor.getText(), "!ls", "this gate never touches bash-mode text; Pi's own onEscape owns it");
1144
+ 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");
1145
+ editor.dispose();
1146
+ });
1147
+
1148
+ test("idle draft: a bash-mode draft with leading whitespace still bypasses the idle-clear gate", (t) => {
1149
+ const { pi, handlers } = fakePi();
1150
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: scopedDoubleEscCancelConfigHome(t) });
1151
+ const { ctx, ui } = fakeContext();
1152
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1153
+ editor.setText(" !ls");
1154
+ let aborted = 0;
1155
+ editor.onEscape = () => { aborted++; };
1156
+ editor.handleInput("\x1b");
1157
+ assert.equal(aborted, 1, "Pi detects bash mode from the trimmed start of the text, so this gate must match the same rule");
1158
+ editor.dispose();
1159
+ });
1160
+
1161
+ // ---------------------------------------------------------------------------
1162
+ // /gentle:double-esc-cancel (issue #1163). Unlike /gentle:background-subagents,
1163
+ // no argument toggles the effective policy rather than merely reporting it.
1164
+ // ---------------------------------------------------------------------------
1165
+
1166
+ test("gentle:double-esc-cancel is registered and declares user-initiated sub-actions with a toggling no-argument form", () => {
1167
+ const { pi, commands } = fakePi();
1168
+ gentleShell(pi, {});
1169
+ const command = commands.get("gentle:double-esc-cancel");
1170
+ assert.ok(command, "gentle:double-esc-cancel must be registered");
1171
+ assert.match(command!.description ?? "", /status\|enable\|disable/);
1172
+ assert.match(command!.description ?? "", /no argument toggles/);
1173
+ });
1174
+
1175
+ test("gentle:double-esc-cancel status reports off by default and writes nothing", async (t) => {
1176
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1177
+ const { pi, commands } = fakePi();
1178
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1179
+ const { ctx, ui } = fakeContext();
1180
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1181
+ assert.equal(ui.notices.length, 1);
1182
+ assert.match(ui.notices[0]!, /^double-esc-cancel: off \(decided by built-in default\)/);
1183
+ assert.equal(existsSync(join(configHome, "double-esc-cancel.json")), false);
1184
+ });
1185
+
1186
+ test("gentle:double-esc-cancel enable writes the global file, reports it, and takes effect immediately", async (t) => {
1187
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1188
+ const { pi, commands } = fakePi();
1189
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1190
+ const { ctx, ui } = fakeContext();
1191
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1192
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on \(decided by global file/);
1193
+ assert.match(ui.notices[0]!, /Wrote on to the global file/);
1194
+ assert.deepEqual(
1195
+ JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")),
1196
+ { schema: "gentle-pi.double-esc-cancel/v1", policy: "on" },
1197
+ );
1198
+ });
1199
+
1200
+ test("gentle:double-esc-cancel disable writes off", async (t) => {
1201
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1202
+ const { pi, commands } = fakePi();
1203
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1204
+ const { ctx, ui } = fakeContext();
1205
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1206
+ await commands.get("gentle:double-esc-cancel")!.handler("disable", ctx);
1207
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off \(decided by global file/);
1208
+ assert.deepEqual(
1209
+ JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")),
1210
+ { schema: "gentle-pi.double-esc-cancel/v1", policy: "off" },
1211
+ );
1212
+ });
1213
+
1214
+ test("gentle:double-esc-cancel with no argument toggles the effective policy each time", async (t) => {
1215
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1216
+ const { pi, commands } = fakePi();
1217
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1218
+ const { ctx, ui } = fakeContext();
1219
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1220
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on /, "off -> on on the first toggle");
1221
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1222
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off /, "on -> off on the second toggle");
1223
+ });
1224
+
1225
+ test("gentle:double-esc-cancel reports a malformed global file as fail-closed, not as an ordinary off", async (t) => {
1226
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1227
+ mkdirSync(configHome, { recursive: true });
1228
+ writeFileSync(join(configHome, "double-esc-cancel.json"), "{malformed");
1229
+ const { pi, commands } = fakePi();
1230
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1231
+ const { ctx, ui } = fakeContext();
1232
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1233
+ assert.match(ui.notices[0]!, /present but malformed/);
1234
+ });
1235
+
1236
+ test("gentle:double-esc-cancel an unknown sub-action warns and changes nothing", async (t) => {
1237
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1238
+ const { pi, commands } = fakePi();
1239
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1240
+ const { ctx, ui } = fakeContext();
1241
+ await commands.get("gentle:double-esc-cancel")!.handler("toggle", ctx);
1242
+ assert.match(ui.notices[0]!, /Unknown \/gentle:double-esc-cancel sub-action "toggle"/);
1243
+ assert.equal(existsSync(join(configHome, "double-esc-cancel.json")), false);
1244
+ });
1245
+
1246
+ 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) => {
1247
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1248
+ const { pi, handlers, commands } = fakePi();
1249
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1250
+ const { ctx, ui } = fakeContext();
1251
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1252
+ let aborted = 0;
1253
+ editor.onEscape = () => { aborted++; };
1254
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
1255
+ editor.handleInput("\x1b");
1256
+ assert.equal(aborted, 1, "off by default: the first Esc still aborts");
1257
+ await commands.get("gentle:double-esc-cancel")!.handler("enable", ctx);
1258
+ editor.handleInput("\x1b");
1259
+ assert.equal(aborted, 1, "now on: the same prompt instance must swallow the first Esc instead of aborting");
1260
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
1261
+ editor.dispose();
1262
+ });
1263
+
1264
+ test("gentle:double-esc-cancel re-syncs the keypress gate from the global file so status, toggle direction, and Esc behavior agree", async (t) => {
1265
+ const configHome = scopedDoubleEscCancelConfigHome(t);
1266
+ const { pi, handlers, commands } = fakePi();
1267
+ gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome });
1268
+ const { ctx, ui } = fakeContext();
1269
+ const editor = installedPrompt(ctx, ui, handlers, escapeKeybindings);
1270
+ let aborted = 0;
1271
+ editor.onEscape = () => { aborted++; };
1272
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
1273
+ // Another session (or a hand edit) turns the preference on underneath this one.
1274
+ mkdirSync(configHome, { recursive: true });
1275
+ writeFileSync(join(configHome, "double-esc-cancel.json"), JSON.stringify({ schema: "gentle-pi.double-esc-cancel/v1", policy: "on" }));
1276
+ await commands.get("gentle:double-esc-cancel")!.handler("status", ctx);
1277
+ assert.match(ui.notices[0]!, /^double-esc-cancel: on \(decided by global file/);
1278
+ editor.handleInput("\x1b");
1279
+ assert.equal(aborted, 0, "status reported on, so the gate must swallow the first Esc rather than abort");
1280
+ assert.match(stripAnsi(editor.render(60).join("\n")), /esc again to cancel/);
1281
+ // The no-argument toggle flips relative to that same on-disk value: on -> off.
1282
+ await commands.get("gentle:double-esc-cancel")!.handler("", ctx);
1283
+ assert.match(ui.notices[1]!, /^double-esc-cancel: off /);
1284
+ assert.deepEqual(JSON.parse(readFileSync(join(configHome, "double-esc-cancel.json"), "utf8")), { schema: "gentle-pi.double-esc-cancel/v1", policy: "off" });
1285
+ editor.dispose();
1286
+ });
1287
+
443
1288
  test("gentleShell leaves an editor another extension already installed", () => {
444
1289
  const { pi, handlers } = fakePi();
445
1290
  gentleShell(pi, {});
@@ -508,6 +1353,15 @@ test("captured changes update the widget and bar without repository scans", asyn
508
1353
  await fire(handlers,"session_shutdown",ctx);
509
1354
  });
510
1355
 
1356
+
1357
+ // The changes overlay may ask Git for each captured root's HEAD label, and
1358
+ // nothing else: no status, diff, numstat or worktree scan behind the user's
1359
+ // back. Labelling is metadata, scanning is the behaviour these tests forbid.
1360
+ const onlyHeadLabels = (git: readonly string[][]) => git.every((args) => {
1361
+ const command = args[0] === "-C" ? args.slice(2) : args;
1362
+ return command[0] === "symbolic-ref" || (command[0] === "rev-parse" && command.includes("--verify"));
1363
+ });
1364
+
511
1365
  test("Changes opens only for captured mutations, not registered dirty roots", async () => {
512
1366
  const {pi,handlers,commands,tools,git}=fakePi();
513
1367
  gentleShell(pi,{});
@@ -522,7 +1376,7 @@ test("Changes opens only for captured mutations, not registered dirty roots", as
522
1376
  const opened=commands.get("gentle:changes")!.handler("",ctx);
523
1377
  await overlayReady;
524
1378
  assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
525
- assert.equal(git.length,0);
1379
+ assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
526
1380
  ui.closeOverlay?.(); await opened;
527
1381
  await fire(handlers,"session_shutdown",ctx);
528
1382
  });
@@ -545,7 +1399,7 @@ test("overlay groups captured roots and refreshes same-count diffs without HEAD
545
1399
  await new Promise(resolve=>setTimeout(resolve,20));
546
1400
  assert.match(ui.overlayView!.render(140).join("\n"),/second/);
547
1401
  assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
548
- assert.equal(git.length,0);
1402
+ assert.ok(onlyHeadLabels(git), `overlay ran more than HEAD labelling: ${JSON.stringify(git)}`);
549
1403
  } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
550
1404
  });
551
1405
 
@@ -859,6 +1713,240 @@ test("a provider switch refreshes the new provider inside the same window", asyn
859
1713
  assert.equal(calls[1].url, "https://cloud-api.nan.builders/api/usage/quota");
860
1714
  });
861
1715
 
1716
+ // A generic hook: any extension can register a usage source for its own
1717
+ // provider on `pi.events`, and gentle-shell dispatches to it exactly like it
1718
+ // dispatches to the built-in Codex/NaN fetchers, without knowing the
1719
+ // provider's name ahead of time.
1720
+
1721
+ function acmeSource(onFetch: (apiKey: string | undefined) => void, plan = "Acme cloud"): unknown {
1722
+ return {
1723
+ schema: USAGE_SOURCE_SCHEMA,
1724
+ provider: "acme-cloud",
1725
+ fetch: async (apiKey: string | undefined) => {
1726
+ onFetch(apiKey);
1727
+ return {
1728
+ provider: "acme-cloud",
1729
+ plan,
1730
+ limits: [{ name: "acme-cloud", windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }], limitReached: false }],
1731
+ fetchedAt: 0,
1732
+ };
1733
+ },
1734
+ };
1735
+ }
1736
+
1737
+ test("gentleShell fetches usage through a source registered before session start", async () => {
1738
+ const { pi, handlers } = fakePi();
1739
+ const seen: Array<string | undefined> = [];
1740
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1741
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource((apiKey) => seen.push(apiKey)));
1742
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1743
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1744
+ await fire(handlers, "session_start", ctx);
1745
+ await new Promise((resolve) => setTimeout(resolve, 0));
1746
+ assert.deepEqual(seen, ["acme-token"], "the registered fetch gets the api key modelRegistry resolves for its provider");
1747
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
1748
+ });
1749
+
1750
+ test("gentleShell forces one refresh when a usage source registers after session start for the active provider", async () => {
1751
+ const { pi, handlers } = fakePi();
1752
+ let calls = 0;
1753
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1754
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1755
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1756
+ await fire(handlers, "session_start", ctx);
1757
+ await new Promise((resolve) => setTimeout(resolve, 0));
1758
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no source registered yet, nothing to show");
1759
+
1760
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
1761
+ await new Promise((resolve) => setTimeout(resolve, 0));
1762
+ assert.equal(calls, 1, "late registration for the active provider triggers one forced refresh, not a wait for the next turn");
1763
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/);
1764
+ });
1765
+
1766
+ test("a usage source registered for a different provider does not force a refresh", async () => {
1767
+ const { pi, handlers } = fakePi();
1768
+ let calls = 0;
1769
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1770
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1771
+ (ctx as unknown as { model: { provider: string } }).model.provider = "openai-codex";
1772
+ await fire(handlers, "session_start", ctx);
1773
+ await new Promise((resolve) => setTimeout(resolve, 0));
1774
+
1775
+ pi.events.emit(USAGE_SOURCE_EVENT, acmeSource(() => { calls += 1; }));
1776
+ await new Promise((resolve) => setTimeout(resolve, 0));
1777
+ assert.equal(calls, 0, "the active provider is codex, so an acme-cloud registration fetches nothing yet");
1778
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
1779
+ });
1780
+
1781
+ test("gentleShell shows the unsupported note for a provider with no built-in or registered source", async () => {
1782
+ const { pi, handlers, commands } = fakePi();
1783
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1784
+ const { ctx, ui } = fakeContext({ token: undefined });
1785
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1786
+ await fire(handlers, "session_start", ctx);
1787
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1788
+ await new Promise((resolve) => setTimeout(resolve, 0));
1789
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1790
+ assert.match(plain[1], /✿ acme-cloud · no subscription usage for this provider/);
1791
+ ui.closeOverlay?.();
1792
+ await opened;
1793
+ });
1794
+
1795
+ test("gentleShell ignores a malformed usage-source registration payload", async () => {
1796
+ const { pi, handlers } = fakePi();
1797
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1798
+ pi.events.emit(USAGE_SOURCE_EVENT, { schema: "wrong-schema", provider: "acme-cloud", fetch: async () => undefined });
1799
+ pi.events.emit(USAGE_SOURCE_EVENT, { schema: USAGE_SOURCE_SCHEMA, provider: "acme-cloud", fetch: "not-a-function" });
1800
+ pi.events.emit(USAGE_SOURCE_EVENT, "not-an-object");
1801
+ pi.events.emit(USAGE_SOURCE_EVENT, undefined);
1802
+ const { ctx, ui } = fakeContext({ token: undefined });
1803
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1804
+ await fire(handlers, "session_start", ctx);
1805
+ await new Promise((resolve) => setTimeout(resolve, 0));
1806
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "no malformed payload registered a usable source");
1807
+ });
1808
+
1809
+ // A registered source is foreign code running inside a fire-and-forget
1810
+ // refresh (`void refreshUsage(...)`, both at session_start and on a late
1811
+ // registration): if its fetch rejects and nothing catches it, the rejection
1812
+ // is unhandled and, under Node's default policy, takes the whole process
1813
+ // down. The doc promises a foreign source is "ignored rather than crashing
1814
+ // the shell", so a throw must degrade exactly like a built-in fetcher's.
1815
+ test("a registered source's rejecting fetch never crashes the shell or poisons the store", async () => {
1816
+ const { pi, handlers, commands } = fakePi();
1817
+ let calls = 0;
1818
+ const rejectingSource = () => ({
1819
+ schema: USAGE_SOURCE_SCHEMA,
1820
+ provider: "acme-cloud",
1821
+ fetch: async () => {
1822
+ calls += 1;
1823
+ throw new Error("acme is down");
1824
+ },
1825
+ });
1826
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1827
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1828
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1829
+
1830
+ const unhandled: unknown[] = [];
1831
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
1832
+ process.on("unhandledRejection", onUnhandled);
1833
+ try {
1834
+ // Registered before session start: session_start's own fire-and-forget
1835
+ // refresh dispatches straight to the rejecting fetch.
1836
+ pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
1837
+ await fire(handlers, "session_start", ctx);
1838
+ await new Promise((resolve) => setTimeout(resolve, 0));
1839
+ assert.equal(calls, 1);
1840
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a rejecting fetch must never be recorded as a snapshot");
1841
+
1842
+ // A second, late registration for the already-active provider dispatches
1843
+ // through the USAGE_SOURCE_EVENT handler's own fire-and-forget refresh.
1844
+ pi.events.emit(USAGE_SOURCE_EVENT, rejectingSource());
1845
+ await new Promise((resolve) => setTimeout(resolve, 0));
1846
+ assert.equal(calls, 2);
1847
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/);
1848
+ } finally {
1849
+ process.off("unhandledRejection", onUnhandled);
1850
+ }
1851
+ assert.deepEqual(unhandled, [], "a foreign source's rejection must never surface as an unhandled rejection");
1852
+
1853
+ // The panel still explains itself with the pending note, never a crash,
1854
+ // even through the awaited refresh openUsage runs on open.
1855
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1856
+ await new Promise((resolve) => setTimeout(resolve, 0));
1857
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1858
+ assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
1859
+ ui.closeOverlay?.();
1860
+ await opened;
1861
+ });
1862
+
1863
+ // A registered source's resolved value is foreign code's own object: it must
1864
+ // be validated like any other parsed payload (never trusted to name the
1865
+ // provider it actually is, never trusted to be well-formed), and a source
1866
+ // that gets replaced mid-flight must never let its late, stale answer land
1867
+ // after the replacement already recorded its own.
1868
+
1869
+ test("a registered source resolving usage for another provider is rejected without overwriting that provider's snapshot", async () => {
1870
+ const { pi, handlers } = fakePi();
1871
+ const { fetchFn } = fakeFetch(NAN_QUOTA_PAYLOAD);
1872
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fetchFn, now: () => 1_788_600_000_000 });
1873
+ const { ctx, ui } = fakeContext({ token: "sk-nan-secret" });
1874
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1875
+ await fire(handlers, "session_start", ctx);
1876
+ await new Promise((resolve) => setTimeout(resolve, 0));
1877
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot recorded first");
1878
+
1879
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1880
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1881
+ schema: USAGE_SOURCE_SCHEMA,
1882
+ provider: "acme-cloud",
1883
+ fetch: async () => ({ provider: "nan", plan: undefined, limits: [], fetchedAt: 0 }),
1884
+ });
1885
+ await new Promise((resolve) => setTimeout(resolve, 0));
1886
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a resolution naming another provider must not surface as the active one");
1887
+
1888
+ (ctx as unknown as { model: { provider: string } }).model.provider = "nan";
1889
+ assert.match(renderFooter(ui), /nan total ▰+▱+/, "the real nan snapshot must survive a mismatched acme-cloud resolution untouched");
1890
+ });
1891
+
1892
+ test("gentleShell leaves the pending note when a registered source resolves a malformed usage", async () => {
1893
+ const { pi, handlers, commands } = fakePi();
1894
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1895
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1896
+ schema: USAGE_SOURCE_SCHEMA,
1897
+ provider: "acme-cloud",
1898
+ fetch: async () => ({ provider: "acme-cloud", plan: "Acme", fetchedAt: 0, limits: "nope" }),
1899
+ });
1900
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1901
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1902
+ await fire(handlers, "session_start", ctx);
1903
+ await new Promise((resolve) => setTimeout(resolve, 0));
1904
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "a malformed result must never be recorded");
1905
+
1906
+ const opened = commands.get("gentle:usage")!.handler("", ctx);
1907
+ await new Promise((resolve) => setTimeout(resolve, 0));
1908
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1909
+ assert.match(plain[1], /✿ acme-cloud · no usage yet · r to fetch/);
1910
+ ui.closeOverlay?.();
1911
+ await opened;
1912
+ });
1913
+
1914
+ test("a slow fetch from a replaced source never records after its replacement resolves", async () => {
1915
+ const { pi, handlers } = fakePi();
1916
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { now: () => 1_788_600_000_000 });
1917
+ const { ctx, ui } = fakeContext({ token: "acme-token" });
1918
+ (ctx as unknown as { model: { provider: string } }).model.provider = "acme-cloud";
1919
+
1920
+ let resolveSlow!: (value: unknown) => void;
1921
+ const slow = new Promise((resolve) => { resolveSlow = resolve; });
1922
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1923
+ schema: USAGE_SOURCE_SCHEMA,
1924
+ provider: "acme-cloud",
1925
+ fetch: async () => {
1926
+ await slow;
1927
+ return { provider: "acme-cloud", plan: "Stale", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 10, windowSeconds: 604_800, resetAt: null }] }] };
1928
+ },
1929
+ });
1930
+ await fire(handlers, "session_start", ctx);
1931
+ await new Promise((resolve) => setTimeout(resolve, 0));
1932
+ assert.doesNotMatch(renderFooter(ui), /acme-cloud/, "the slow fetch has not resolved yet");
1933
+
1934
+ // Replace the source before the slow fetch resolves; the late registration
1935
+ // forces its own refresh, which resolves immediately.
1936
+ pi.events.emit(USAGE_SOURCE_EVENT, {
1937
+ schema: USAGE_SOURCE_SCHEMA,
1938
+ provider: "acme-cloud",
1939
+ fetch: async () => ({ provider: "acme-cloud", plan: "Fresh", fetchedAt: 0, limits: [{ name: "acme-cloud", limitReached: false, windows: [{ label: "week", usedPercent: 40, windowSeconds: 604_800, resetAt: null }] }] }),
1940
+ });
1941
+ await new Promise((resolve) => setTimeout(resolve, 0));
1942
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the replacement's own refresh recorded first");
1943
+
1944
+ // Now let the stale fetch resolve; it must never overwrite the fresh record.
1945
+ resolveSlow(undefined);
1946
+ await new Promise((resolve) => setTimeout(resolve, 0));
1947
+ assert.match(renderFooter(ui), /acme-cloud week ▰▰▰▱▱▱▱▱ 40%/, "the stale refresh must never record after being replaced");
1948
+ });
1949
+
862
1950
  test("gentleShell records SSE rate-limit headers from provider responses", async () => {
863
1951
  const { pi, handlers } = fakePi();
864
1952
  gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch({}, false).fetchFn, now: () => 0 });
@@ -891,6 +1979,32 @@ test("gentleShell registers /gentle:usage and opens the subscriptions overlay",
891
1979
  await opened;
892
1980
  });
893
1981
 
1982
+ test("usageShortcut defaults to alt+u and can be overridden or disabled", () => {
1983
+ assert.equal(usageShortcut({}), "alt+u");
1984
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "ctrl+shift+u" }), "ctrl+shift+u");
1985
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "off" }), undefined);
1986
+ assert.equal(usageShortcut({ GENTLE_PI_SHELL_USAGE_KEY: "" }), undefined);
1987
+ });
1988
+
1989
+ test("gentleShell binds the usage shortcut to the same handler as /gentle:usage", async () => {
1990
+ const { pi, handlers, shortcuts } = fakePi();
1991
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch().fetchFn, now: () => 1_788_600_000_000 });
1992
+ const { ctx, ui } = fakeContext({ token: JWT });
1993
+ await fire(handlers, "session_start", ctx);
1994
+ const shortcut = shortcuts.get("alt+u");
1995
+ assert.ok(shortcut, "alt+u not registered");
1996
+ const opened = shortcut.handler(ctx);
1997
+ await new Promise((resolve) => setTimeout(resolve, 0));
1998
+ const plain = ui.overlayView!.render(90).map(stripAnsi);
1999
+ assert.match(plain[0], /Subscriptions/);
2000
+ ui.closeOverlay?.();
2001
+ await opened;
2002
+
2003
+ const silent = fakePi();
2004
+ gentleShell(silent.pi, { GENTLE_PI_SHELL_USAGE_KEY: "off" });
2005
+ assert.equal(silent.shortcuts.has("alt+u"), false, "the usage shortcut must not register when disabled");
2006
+ });
2007
+
894
2008
  test("gentleShell draws the review preflight message as a Gentle card", () => {
895
2009
  const { pi } = fakePi();
896
2010
  gentleShell(pi, {});
@@ -909,6 +2023,17 @@ test("gentleShell draws the review preflight message as a Gentle card", () => {
909
2023
  assert.equal(collapsed.length, 3);
910
2024
  });
911
2025
 
2026
+ test("the review preflight card paints the rose INFO frame (border) and title (accent)", () => {
2027
+ const { pi } = fakePi();
2028
+ gentleShell(pi, {});
2029
+ const renderer = renderers.get("gentle-pi.review-preflight")!;
2030
+ const taggedTheme = { ...plainTheme, fg: (color: string, text: string) => `<${color}>${text}</${color}>` };
2031
+ const message = { customType: "gentle-pi.review-preflight", content: "Receipt-driven development is enabled." };
2032
+ const lines = renderer(message, { expanded: true }, taggedTheme).render(60);
2033
+ assert.match(lines[0]!, /^<border>╭<\/border>/);
2034
+ assert.match(lines[0]!, /<accent>✿ Gentle AI<\/accent>/);
2035
+ });
2036
+
912
2037
  test("gentleShell keeps a dev-binary override visible above the editor for the whole session", async () => {
913
2038
  const { pi, handlers } = fakePi();
914
2039
  const deps = { fetch: fakeFetch({}, false).fetchFn, now: () => 0, devBinary: () => ({ state: "active" as const, path: "/Users/me/go/bin/gentle-ai", sha256: "6e53bfc6305a3949deadbeef" }) };