gentle-pi 2.6.4 → 3.0.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 (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -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, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, 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
- import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
8
8
  import type { TUI } from "@earendil-works/pi-tui";
9
- import installGentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
9
+ import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, 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";
@@ -63,12 +63,25 @@ interface ShortcutRegistration {
63
63
  type MessageRenderer = (message: { customType: string; content: unknown }, options: { expanded: boolean }, theme: unknown) => { render(width: number): string[] };
64
64
  const renderers = new Map<string, MessageRenderer>();
65
65
 
66
- function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
66
+ const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
67
+
68
+ const DEFAULT_COMMANDS: SlashCommandInfo[] = [
69
+ { name: "gentle:models", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
70
+ { name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
71
+ { name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
72
+ { name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
73
+ { name: "gentle:commands", description: "Open the command palette", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
74
+ { name: "gentle:not-in-catalog", description: "Not a curated command", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
75
+ { name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
76
+ ];
77
+
78
+ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }], commandsList: SlashCommandInfo[] = DEFAULT_COMMANDS) {
67
79
  const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>();
68
80
  const commands = new Map<string, CommandRegistration>();
69
81
  const shortcuts = new Map<string, ShortcutRegistration>();
70
82
  const git: string[][] = [];
71
83
  let entries: unknown[] = [];
84
+ const sentMessages: Array<{ content: string; options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean } }> = [];
72
85
  const tools = new Map<string, { renderShell?: string; execute(id: string, params: unknown, signal: undefined, update: undefined, ctx: ExtensionContext): Promise<unknown> }>();
73
86
  const listeners = new Map<string, (data: unknown) => void>();
74
87
  let round = 0;
@@ -94,6 +107,12 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
94
107
  getThinkingLevel() {
95
108
  return "medium";
96
109
  },
110
+ getCommands() {
111
+ return commandsList;
112
+ },
113
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp"; expandPromptTemplates?: boolean }) {
114
+ sentMessages.push({ content, options });
115
+ },
97
116
  async exec(_command: string, args: string[]) {
98
117
  git.push(args);
99
118
  if (args.includes("worktree")) return { stdout: "worktree /repo\0branch refs/heads/main\0\0", stderr: "", code: 0, killed: false };
@@ -102,7 +121,7 @@ function fakePi(script: GitScript[] = [{ numstat: "", porcelain: "" }]) {
102
121
  return { stdout: isNumstat ? step.numstat : step.porcelain, stderr: "", code: 0, killed: false };
103
122
  },
104
123
  } as unknown as ExtensionAPI;
105
- return { pi, handlers, git, commands, shortcuts, tools };
124
+ return { pi, handlers, git, commands, shortcuts, tools, sentMessages };
106
125
  }
107
126
 
108
127
  async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>, event: string, ctx: ExtensionContext): Promise<void> {
@@ -149,11 +168,15 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo
149
168
  setWorkingVisible(visible: boolean) {
150
169
  ui.workingVisible = visible;
151
170
  },
152
- custom(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: null) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
171
+ // Generic over the result type: real callers resolve `custom` with
172
+ // whatever their `done` callback is given (see ExtensionUIContext.custom
173
+ // in pi-coding-agent), not always null. `closeOverlay` stays a
174
+ // null-resolving escape hatch for tests that only need to end the wait.
175
+ custom<T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: T) => void) => { render(width: number): string[]; handleInput(data: string): void }) {
153
176
  ui.overlay = factory;
154
- return new Promise<null>((resolve) => {
177
+ return new Promise<T | null>((resolve) => {
155
178
  ui.closeOverlay = () => resolve(null);
156
- ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, () => resolve(null));
179
+ ui.overlayView = factory(fakeTui, plainTheme, fakeKeybindings, (value: T) => resolve(value));
157
180
  resolveOverlay();
158
181
  });
159
182
  },
@@ -232,7 +255,8 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
232
255
 
233
256
  test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
234
257
  const { pi, handlers } = fakePi();
235
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
258
+ let profile: string | undefined = "team";
259
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
236
260
  const entries: unknown[] = [];
237
261
  const { ctx, ui } = fakeContext({ entries });
238
262
  await fire(handlers, "session_start", ctx);
@@ -248,6 +272,14 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
248
272
  assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
249
273
  assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
250
274
 
275
+ assert.match(rail.render(46).join("\n"), /Profile.*team/);
276
+ const beforeProfile = live();
277
+ profile = "other";
278
+ assert.notEqual(live(), beforeProfile);
279
+ assert.match(rail.render(46).join("\n"), /Profile.*other/);
280
+ profile = undefined;
281
+ assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
282
+
251
283
  const beforeModel = live();
252
284
  (ctx.model as { id: string }).id = "gpt-5.6";
253
285
  assert.notEqual(live(), beforeModel, "/model must change the digest");
@@ -273,10 +305,44 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
273
305
  }
274
306
  });
275
307
 
308
+ test("profile reader follows store changes and rejects missing or invalid active markers", (t) => {
309
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-"));
310
+ t.after(() => rmSync(root, { recursive: true, force: true }));
311
+ const path = join(root, "profiles.json");
312
+ const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
313
+ const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({
314
+ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} },
315
+ }));
316
+ assert.equal(read(), undefined);
317
+ save("team");
318
+ assert.equal(read(), "team");
319
+ assert.equal(read(), "team");
320
+ save("other");
321
+ assert.equal(read(), "other");
322
+ const replacement = join(root, "replacement.json");
323
+ writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } }));
324
+ renameSync(replacement, path);
325
+ assert.equal(read(), "team", "atomic replacement refreshes the cached profile");
326
+ const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") });
327
+ assert.equal(isolated(), undefined);
328
+ assert.equal(read(), "team", "another shell's config home does not alter this cache");
329
+ save("missing");
330
+ assert.equal(read(), undefined);
331
+ save(undefined);
332
+ assert.equal(read(), undefined);
333
+ writeFileSync(path, "{broken");
334
+ assert.equal(read(), undefined);
335
+ save("team");
336
+ assert.equal(read(), "team");
337
+ rmSync(path);
338
+ assert.equal(read(), undefined);
339
+ });
340
+
276
341
  test("gentleShell stays out of the way without a UI or when disabled", () => {
277
342
  const disabled = fakePi();
278
343
  gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
279
- assert.equal(disabled.handlers.size, 0);
344
+ assert.equal(disabled.commands.size, 0);
345
+ assert.ok(disabled.handlers.has("tool_call"), "capture remains available to headless children");
280
346
 
281
347
  const headless = fakePi();
282
348
  gentleShell(headless.pi, {});
@@ -339,15 +405,41 @@ test("gentleShell shows working while the agent runs and queued when messages wa
339
405
  const editor = installedPrompt(ctx, ui, handlers);
340
406
 
341
407
  for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
342
- assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] working ─+╮$/);
408
+ assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ working… ─+╮$/);
343
409
  pending.value = true;
344
410
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ [✿❀❁✾] queued ─+╮$/);
345
411
  for (const handler of handlers.get("agent_end") ?? []) handler({}, ctx);
412
+ assert.match(stripAnsi(editor.render(60)[0]), /queued/, "low-level run end is not settled");
346
413
  pending.value = false;
414
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
347
415
  assert.match(stripAnsi(editor.render(60)[0]), /^╭─ ✿ ─+╮$/);
348
416
  editor.dispose();
349
417
  });
350
418
 
419
+ test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => {
420
+ const delays: number[] = [];
421
+ let active = 0;
422
+ let unrefs = 0;
423
+ t.mock.method(globalThis, "setInterval", (_callback: () => void, delay: number) => {
424
+ delays.push(delay);
425
+ active++;
426
+ return { unref() { unrefs++; } };
427
+ });
428
+ t.mock.method(globalThis, "clearInterval", () => { active--; });
429
+ const { pi, handlers } = fakePi();
430
+ gentleShell(pi, {});
431
+ const { ctx, ui } = fakeContext();
432
+ const editor = installedPrompt(ctx, ui, handlers);
433
+ assert.equal(active, 0);
434
+ for (const handler of handlers.get("agent_start") ?? []) handler({}, ctx);
435
+ assert.deepEqual(delays, [80]);
436
+ assert.equal(unrefs, 1);
437
+ for (const handler of handlers.get("agent_settled") ?? []) handler({}, ctx);
438
+ assert.equal(active, 0);
439
+ editor.dispose();
440
+ assert.equal(active, 0);
441
+ });
442
+
351
443
  test("gentleShell leaves an editor another extension already installed", () => {
352
444
  const { pi, handlers } = fakePi();
353
445
  gentleShell(pi, {});
@@ -355,6 +447,26 @@ test("gentleShell leaves an editor another extension already installed", () => {
355
447
  const { ctx, ui } = fakeContext({ editorFactory: theirs });
356
448
  for (const handler of handlers.get("session_start") ?? []) handler({}, ctx);
357
449
  assert.equal(ui.editorFactory, theirs);
450
+ assert.notEqual(ui.workingVisible, false, "a custom owner still needs native working feedback");
451
+ });
452
+
453
+ test("Gentle replaces its retained factory on reload so new lifecycle handlers own the prompt", () => {
454
+ const first = fakePi();
455
+ gentleShell(first.pi, {});
456
+ const { ctx, ui } = fakeContext();
457
+ const oldEditor = installedPrompt(ctx, ui, first.handlers);
458
+ const previousFactory = ui.editorFactory;
459
+ const next = fakePi();
460
+ gentleShell(next.pi, {});
461
+ const editor = installedPrompt(ctx, ui, next.handlers);
462
+ try {
463
+ assert.notEqual(ui.editorFactory, previousFactory);
464
+ for (const handler of next.handlers.get("agent_start") ?? []) handler({}, ctx);
465
+ assert.match(stripAnsi(editor.render(60)[0]), /working/);
466
+ } finally {
467
+ oldEditor.dispose();
468
+ editor.dispose();
469
+ }
358
470
  });
359
471
 
360
472
  const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} };
@@ -364,152 +476,94 @@ function renderFooter(ui: FakeUi): string {
364
476
  return factory(fakeTui, plainTheme, footerData).render(160)[0];
365
477
  }
366
478
 
367
- test("gentleShell shows working-tree changes in a widget below the editor and in the bar", async () => {
368
- const { pi, handlers, git } = fakePi([
369
- { numstat: "", porcelain: "" },
370
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
371
- ]);
372
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
373
- const { ctx, ui } = fakeContext();
374
- await fire(handlers, "session_start", ctx);
375
- assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
376
- assert.equal(ui.widgets.has("gentle-shell-changes"), false);
377
- assert.doesNotMatch(renderFooter(ui), /±/);
378
-
379
- await fire(handlers, "tool_execution_end", ctx);
380
- const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
381
- const [line] = factory(fakeTui, plainTheme).render(120);
382
- assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
383
- assert.match(renderFooter(ui), /main ±1/);
384
- });
385
-
386
- test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
387
- const { pi, handlers, commands } = fakePi([
388
- { numstat: "", porcelain: "" },
389
- { numstat: "", porcelain: "" },
390
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
391
- ]);
392
- gentleShell(pi, {});
393
- const { ctx, ui } = fakeContext();
394
- await fire(handlers, "session_start", ctx);
395
- const command = commands.get("gentle:changes");
396
- assert.ok(command, "command not registered");
479
+ function sessionChange(ctx: ExtensionContext, id: string, root: string, path: string, before = "", after = "agent\n"): void {
480
+ const entries = ctx.sessionManager.getEntries() as any[];
481
+ entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
482
+ sessionId: ctx.sessionManager.getSessionId(),
483
+ evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
484
+ } });
485
+ }
397
486
 
398
- await command.handler("", ctx);
399
- assert.deepEqual(ui.notices, ["No changes in the working tree."]);
400
- assert.equal(ui.overlay, undefined);
487
+ test("captured changes update the widget and bar without repository scans", async () => {
488
+ const { pi, handlers, git } = fakePi([{numstat:"999\t0\tforeign.ts\n",porcelain:"?? foreign.ts\0"}]);
489
+ gentleShell(pi,{});
490
+ const {ctx,ui}=fakeContext();
491
+ await fire(handlers,"session_start",ctx);
492
+ assert.equal(git.length,0);
493
+ assert.equal(ui.widgets.has("gentle-shell-changes"),false);
494
+ sessionChange(ctx,"a","/repo","lib/b.ts","","one\ntwo\n");
495
+ pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
496
+ await new Promise(resolve=>setImmediate(resolve));
497
+ const factory=ui.widgets.get("gentle-shell-changes") as any;
498
+ assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
499
+ assert.match(renderFooter(ui),/main ±1/);
500
+ const rail = sidebarState(fakeTui as unknown as TUI).parts.get("footer")!;
501
+ const digest = rail.digest!();
502
+ assert.match(rail.render(46).join("\n"), /1 file · \+2 −0/);
503
+ sessionChange(ctx,"b","/repo","lib/b.ts","one\ntwo\n","one\ntwo\nthree\n");
504
+ await fire(handlers,"agent_end",ctx);
505
+ assert.notEqual(rail.digest!(), digest, "same-count line changes invalidate the unified Status");
506
+ assert.match(rail.render(46).join("\n"), /1 file · \+3 −0/);
507
+ assert.equal(git.length,0);
508
+ await fire(handlers,"session_shutdown",ctx);
509
+ });
401
510
 
402
- await fire(handlers, "tool_execution_end", ctx);
403
- const opened = command.handler("", ctx);
404
- await new Promise((resolve) => setTimeout(resolve, 0));
405
- assert.equal(typeof ui.overlay, "function");
406
- ui.closeOverlay?.();
407
- await opened;
511
+ test("Changes opens only for captured mutations, not registered dirty roots", async () => {
512
+ const {pi,handlers,commands,tools,git}=fakePi();
513
+ gentleShell(pi,{});
514
+ const {ctx,ui,overlayReady}=fakeContext();
515
+ await fire(handlers,"session_start",ctx);
516
+ await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
517
+ await commands.get("gentle:changes")!.handler("",ctx);
518
+ assert.match(ui.notices.join("\n"),/No captured agent changes/);
519
+ assert.equal(ui.overlay,undefined);
520
+ sessionChange(ctx,"a","/linked","file.ts");
521
+ await fire(handlers,"agent_end",ctx);
522
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
523
+ await overlayReady;
524
+ assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
525
+ assert.equal(git.length,0);
526
+ ui.closeOverlay?.(); await opened;
527
+ await fire(handlers,"session_shutdown",ctx);
408
528
  });
409
529
 
410
- test("changes overlay follows registered linked roots on open, refresh and polling and scopes diff git cwd", async () => {
411
- const { pi, handlers, commands, tools } = fakePi();
412
- let roots = ["/repo"];
413
- const diffs: string[][] = [];
414
- pi.exec = (async (_command: string, args: string[]) => {
415
- let stdout = "";
416
- if (args.includes("worktree")) stdout = roots.map((root) => `worktree ${root}\0branch refs/heads/${root.slice(1)}\0\0`).join("");
417
- else if (args.includes("status")) stdout = " M same.ts\0";
418
- else if (args.includes("--numstat")) stdout = "1\t1\tsame.ts\n";
419
- else { diffs.push(args); stdout = `+${args[1]}`; }
420
- return { stdout, stderr: "", code: 0, killed: false };
421
- }) as ExtensionAPI["exec"];
422
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
423
- const { ctx, ui } = fakeContext();
424
- await fire(handlers, "session_start", ctx);
425
- roots.push("/linked");
426
- await tools.get("session_worktree_register")!.execute("register", { path: "/linked" }, undefined, undefined, ctx);
427
- const open = commands.get("gentle:changes")!.handler("", ctx);
428
- try {
429
- await new Promise((resolve) => setTimeout(resolve, 20));
430
- assert.match(ui.overlayView!.render(120).join("\n"), /linked · linked/);
431
- assert.equal(diffs.length, 0);
432
- ui.overlayView!.handleInput("j");
433
- ui.overlayView!.handleInput("\r");
434
- ui.overlayView!.handleInput("j");
435
- await new Promise((resolve) => setTimeout(resolve, 0));
436
- assert.deepEqual(diffs[0], ["-C", "/linked", "diff", "HEAD", "--", "same.ts"]);
437
- ui.overlayView!.handleInput("\x1b[D");
438
- roots.push("/new");
439
- await tools.get("session_worktree_register")!.execute("register", { path: "/new" }, undefined, undefined, ctx);
440
- ui.overlayView!.handleInput("r");
441
- await new Promise((resolve) => setTimeout(resolve, 20));
442
- assert.match(ui.overlayView!.render(120).join("\n"), /new · new/);
443
- roots.push("/polled");
444
- await tools.get("session_worktree_register")!.execute("register", { path: "/polled" }, undefined, undefined, ctx);
445
- await new Promise((resolve) => setTimeout(resolve, 20));
446
- assert.match(ui.overlayView!.render(120).join("\n"), /polled · polled/);
447
- } finally {
448
- ui.closeOverlay?.();
449
- await open;
450
- await fire(handlers, "session_shutdown", ctx);
451
- }
530
+ test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
531
+ const {pi,handlers,commands,git}=fakePi();
532
+ gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
533
+ const {ctx,ui,overlayReady}=fakeContext();
534
+ sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
535
+ sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
536
+ await fire(handlers,"session_start",ctx);
537
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
538
+ await overlayReady;
539
+ try {
540
+ ui.overlayView!.handleInput("\r"); ui.overlayView!.handleInput("j");
541
+ await new Promise(resolve=>setTimeout(resolve,10));
542
+ assert.match(ui.overlayView!.render(140).join("\n"),/first/);
543
+ sessionChange(ctx,"b","/repo","same.ts","first\n","second\n");
544
+ pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
545
+ await new Promise(resolve=>setTimeout(resolve,20));
546
+ assert.match(ui.overlayView!.render(140).join("\n"),/second/);
547
+ assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
548
+ assert.equal(git.length,0);
549
+ } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
452
550
  });
453
551
 
454
- test("successful direct tools register whole roots, failures and shell contents do not, and all UI counts agree", async () => {
455
- const { pi, handlers, tools, commands } = fakePi();
456
- const queried: string[] = [];
457
- pi.exec = (async (_command: string, args: string[]) => {
458
- const root = args[1];
459
- if (args.includes("status")) queried.push(root);
460
- const stdout = args.includes("worktree") ? ["/repo", "/used", "/failed", "/opaque", "/hidden"].map((path) => `worktree ${path}\0\0`).join("")
461
- : args.includes("status") ? " M preexisting.ts\0?? new.ts\0"
462
- : args.includes("--numstat") ? "2\t1\tpreexisting.ts\n" : "+diff";
463
- return { stdout, stderr: "", code: 0, killed: false };
464
- }) as ExtensionAPI["exec"];
465
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
466
- const { ctx, ui, overlayReady } = fakeContext();
467
- await fire(handlers, "session_start", ctx);
468
- const emit = async (name: string, event: unknown) => {
469
- for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
470
- };
471
- const complete = async (toolName: string, input: unknown, isError = false) => {
472
- await emit("tool_execution_start", { toolCallId: "call", toolName, args: input });
473
- await emit("tool_result", { toolCallId: "call", toolName, input, isError: false });
474
- await emit("tool_execution_end", { toolCallId: "call", toolName, isError });
475
- };
476
- await complete("read", { path: "/failed" }, true);
477
- await complete("bash", { command: "cd /opaque && edit stuff", path: "/opaque" });
478
- assert.deepEqual(new Set(queried), new Set(["/repo"]));
479
- await complete("read", { path: "/used" });
480
- await complete("write", { path: "/used" });
481
- assert.deepEqual(new Set(queried), new Set(["/repo", "/used"]));
482
- assert.equal(ctx.sessionManager.getEntries().length, 2, "multiple completed tools dedupe");
483
- assert.match(renderFooter(ui), /±4/);
484
- const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
485
- assert.match(widget(fakeTui, plainTheme).render(140)[0], /4 files/);
486
- const open = commands.get("gentle:changes")!.handler("", ctx);
487
- await overlayReady;
488
- assert.doesNotMatch(ui.overlayView!.render(140).join("\n"), /hidden|opaque|failed/);
489
- ui.closeOverlay?.();
490
- await open;
491
- await tools.get("session_worktree_register")!.execute("explicit", { path: "/opaque" }, undefined, undefined, ctx);
492
- assert.match(renderFooter(ui), /±6/);
493
- await fire(handlers, "session_shutdown", ctx);
494
- });
495
-
496
- test("session switching drops pending tool and foreign launch events while same-session entries restore", async () => {
497
- const h = fakePi([{ numstat: "1\t0\ta.ts\n", porcelain: " M a.ts\0" }]);
498
- gentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
499
- const first = fakeContext();
500
- await fire(h.handlers, "session_start", first.ctx);
501
- for (const handler of h.handlers.get("tool_execution_start") ?? []) await handler({ toolCallId: "old", toolName: "read", args: { path: "/foreign" } }, first.ctx);
502
- const next = fakeContext({ entries: [...first.ctx.sessionManager.getEntries()] });
503
- (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "new-session";
504
- await fire(h.handlers, "session_start", next.ctx);
505
- for (const handler of h.handlers.get("tool_result") ?? []) await handler({ toolCallId: "old", toolName: "read", input: { path: "/foreign" } }, next.ctx);
506
- for (const handler of h.handlers.get("tool_execution_end") ?? []) await handler({ toolCallId: "old", toolName: "read", isError: false }, next.ctx);
507
- const before = h.git.length;
508
- h.pi.events.emit("gentle-pi:session-worktree-changed", { sessionId: "shell-session", root: "/foreign" });
509
- assert.equal(h.git.length, before);
510
- assert.match(renderFooter(next.ui), /±1/);
511
- assert.ok(!h.git.some((args) => args[1] === "/foreign"));
512
- await fire(h.handlers, "session_shutdown", next.ctx);
552
+ test("new sessions ignore inherited captures, while reload restores the same session", async () => {
553
+ const h=fakePi(); gentleShell(h.pi,{});
554
+ const first=fakeContext();
555
+ sessionChange(first.ctx,"a","/repo","own.ts");
556
+ await fire(h.handlers,"session_start",first.ctx);
557
+ assert.match(renderFooter(first.ui),/±1/);
558
+ await fire(h.handlers,"session_start",first.ctx);
559
+ assert.match(renderFooter(first.ui),/±1/);
560
+ const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
561
+ (next.ctx.sessionManager as any).getSessionId=()=>"new-session";
562
+ await fire(h.handlers,"session_start",next.ctx);
563
+ h.pi.events.emit("gentle-pi:session-change",{sessionId:"shell-session"});
564
+ assert.doesNotMatch(renderFooter(next.ui),/±1/);
565
+ assert.equal(h.git.length,0);
566
+ await fire(h.handlers,"session_shutdown",next.ctx);
513
567
  });
514
568
 
515
569
  test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
@@ -550,10 +604,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
550
604
  installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
551
605
  await fire(h.handlers, "session_start", ctx);
552
606
  t.after(() => fire(h.handlers, "session_shutdown", ctx));
553
- const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
554
- const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
555
- assert.match(rendered, /selected-only\.txt/);
556
- assert.doesNotMatch(rendered, /foreign-only\.txt/);
607
+ assert.equal(ui.widgets.has("gentle-shell-changes"), false, "preexisting dirty files are not agent changes");
557
608
  const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
558
609
  assert.match(diff, /\+selected change/);
559
610
  assert.doesNotMatch(diff, /foreign change/);
@@ -567,12 +618,8 @@ test("registered canonical root governs real Git discovery, status and diff desp
567
618
  assert.equal(largeDiff.code, 0);
568
619
  assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
569
620
  assert.match(largeDiff.stdout, /\+selected final marker/);
570
- const open = h.commands.get("gentle:changes")!.handler("", ctx);
571
- await overlayReady;
572
- ui.overlayView!.handleInput("\r");
573
- ui.overlayView!.handleInput("j");
574
- ui.closeOverlay?.();
575
- await open;
621
+ await h.commands.get("gentle:changes")!.handler("", ctx);
622
+ assert.equal(ui.overlay, undefined);
576
623
  });
577
624
 
578
625
  test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
@@ -644,55 +691,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
644
691
  const shortcut = shortcuts.get("alt+g");
645
692
  assert.ok(shortcut, "alt+g not registered");
646
693
  await shortcut.handler(ctx);
647
- assert.deepEqual(ui.notices, ["No changes in the working tree."]);
694
+ assert.match(ui.notices.join("\n"), /No captured agent changes/);
648
695
 
649
696
  const silent = fakePi();
650
697
  gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
651
- assert.equal(silent.shortcuts.size, 0);
652
- });
653
-
654
- test("gentleShell keeps the open overlay in sync with git while it stays open", async () => {
655
- const { pi, handlers, commands } = fakePi([
656
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
657
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
658
- { numstat: "10\t0\tlib/b.ts\n3\t1\tlib/c.ts\n", porcelain: "A lib/b.ts\0 M lib/c.ts\0" },
659
- ]);
660
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
661
- const { ctx, ui } = fakeContext();
662
- await fire(handlers, "session_start", ctx);
663
- const open = commands.get("gentle:changes")!.handler("", ctx);
664
- await new Promise((resolve) => setTimeout(resolve, 40));
665
- ui.overlayView!.handleInput("\r");
666
- const plain = ui.overlayView!.render(100).map(stripAnsi);
667
- assert.match(plain[2], /2 files · \+13 −1/);
668
- assert.match(plain[3], /lib\/c\.ts/);
669
- assert.match(renderFooter(ui), /main ±2/);
670
- ui.closeOverlay?.();
671
- await open;
698
+ assert.equal(silent.shortcuts.has("alt+g"), false, "the changes shortcut must not register when disabled");
672
699
  });
673
700
 
674
- test("gentleShell watches git in the background so the widget and bar follow external edits", async () => {
675
- const { pi, handlers, git } = fakePi([
676
- { numstat: "", porcelain: "" },
677
- { numstat: "", porcelain: "" },
678
- { numstat: "3\t1\tlib/c.ts\n", porcelain: " M lib/c.ts\0" },
679
- ]);
680
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "5" });
681
- const { ctx, ui } = fakeContext();
682
- await fire(handlers, "session_start", ctx);
683
- assert.equal(ui.widgets.has("gentle-shell-changes"), false);
684
- await new Promise((resolve) => setTimeout(resolve, 40));
685
- assert.equal(ui.widgets.has("gentle-shell-changes"), true);
686
- assert.match(renderFooter(ui), /main ±1/);
687
- assert.ok(git.length >= 6, "background watch should keep polling git");
688
-
689
- const widgetSetsBefore = ui.widgetSets;
690
- await new Promise((resolve) => setTimeout(resolve, 20));
691
- assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
692
- await fire(handlers, "session_shutdown", ctx);
693
- const gitCallsAfterShutdown = git.length;
694
- await new Promise((resolve) => setTimeout(resolve, 20));
695
- assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
701
+ test("external edits do not pollute Changes or trigger background Git scans", async () => {
702
+ const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
703
+ gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
704
+ const {ctx,ui}=fakeContext();
705
+ await fire(handlers,"session_start",ctx);
706
+ await new Promise(resolve=>setTimeout(resolve,30));
707
+ await fire(handlers,"agent_end",ctx);
708
+ assert.equal(git.length,0);
709
+ assert.equal(ui.widgets.has("gentle-shell-changes"),false);
710
+ assert.doesNotMatch(renderFooter(ui),/±/);
711
+ await fire(handlers,"session_shutdown",ctx);
696
712
  });
697
713
 
698
714
  const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
@@ -816,3 +832,120 @@ test("gentleShell keeps a dev-binary override visible above the editor for the w
816
832
 
817
833
  assert.equal(devBinaryCard({ state: "invalid", reason: "binary missing" }).tone, "error");
818
834
  });
835
+
836
+ test("gentle:commands registers alt+k by default", () => {
837
+ const { pi, shortcuts } = fakePi();
838
+ gentleShell(pi, {});
839
+ assert.ok(shortcuts.has("alt+k"));
840
+ });
841
+
842
+ test("gentle:commands honors GENTLE_PI_COMMANDS_KEY", () => {
843
+ const { pi, shortcuts } = fakePi();
844
+ gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "ctrl+p" });
845
+ assert.ok(shortcuts.has("ctrl+p"));
846
+ assert.equal(shortcuts.has("alt+k"), false);
847
+ });
848
+
849
+ test("GENTLE_PI_COMMANDS_KEY=off registers no command-palette shortcut", () => {
850
+ const { pi, shortcuts } = fakePi();
851
+ gentleShell(pi, { GENTLE_PI_COMMANDS_KEY: "off" });
852
+ assert.equal(shortcuts.has("alt+k"), false);
853
+ assert.ok(shortcuts.has("alt+g"), "the unrelated changes shortcut still registers");
854
+ });
855
+
856
+ test("/gentle:commands shows only curated, registered commands, grouped, by their labels", async () => {
857
+ const { pi, commands } = fakePi();
858
+ gentleShell(pi, {});
859
+ const { ctx, ui, overlayReady } = fakeContext();
860
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
861
+ await overlayReady;
862
+ const lines = ui.overlayView!.render(100);
863
+ const rendered = lines.join("\n");
864
+ assert.match(rendered, /Configuration/);
865
+ assert.match(rendered, /Session/);
866
+ assert.match(rendered, /Diagnostics/);
867
+ assert.match(rendered, /Skills/);
868
+ assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
869
+ assert.match(rendered, /Assign models and effort/);
870
+ assert.match(rendered, /Browse captured changes/);
871
+ assert.match(rendered, /Gentle AI status/);
872
+ assert.match(rendered, /Refresh skill registry/);
873
+ assert.doesNotMatch(rendered, /gentle:models|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
874
+ assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
875
+ assert.doesNotMatch(rendered, /skill:foo/);
876
+ const changesLine = lines.find((line) => line.includes("Browse captured changes"));
877
+ assert.match(changesLine ?? "", /alt\+g/, "expected the configured alt+g shortcut hint next to Browse captured changes");
878
+ ui.overlayView!.handleInput("\x1b");
879
+ await opened;
880
+ });
881
+
882
+ test("selecting a command from the palette by its label sends the underlying command as a slash message", async () => {
883
+ const { pi, commands, sentMessages } = fakePi();
884
+ gentleShell(pi, {});
885
+ const { ctx, ui, overlayReady } = fakeContext();
886
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
887
+ await overlayReady;
888
+ // "mod" only matches the "Assign models and effort" label (it contains
889
+ // "mod" via "models"); nothing else in the fixture does.
890
+ for (const ch of "mod") ui.overlayView!.handleInput(ch);
891
+ assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
892
+ ui.overlayView!.handleInput("\r");
893
+ await opened;
894
+ assert.deepEqual(sentMessages, [{ content: "/gentle:models", options: { expandPromptTemplates: true } }]);
895
+ });
896
+
897
+ test("escaping the palette sends no message", async () => {
898
+ const { pi, commands, sentMessages } = fakePi();
899
+ gentleShell(pi, {});
900
+ const { ctx, ui, overlayReady } = fakeContext();
901
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
902
+ await overlayReady;
903
+ ui.overlayView!.handleInput("\x1b");
904
+ await opened;
905
+ assert.deepEqual(sentMessages, []);
906
+ });
907
+
908
+ test("/gentle:commands notifies when nothing in the catalog is registered", async () => {
909
+ const { pi, commands } = fakePi(undefined, [
910
+ { name: "skill:foo", description: "A skill", source: "skill", sourceInfo: FAKE_SOURCE_INFO },
911
+ { name: "gentle:not-in-catalog", description: "Not curated", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
912
+ ]);
913
+ gentleShell(pi, {});
914
+ const { ctx, ui } = fakeContext();
915
+ await commands.get("gentle:commands")!.handler("", ctx);
916
+ assert.match(ui.notices.join("\n"), /No Gentle commands are registered\./);
917
+ assert.equal(ui.overlay, undefined);
918
+ });
919
+
920
+ test("/gentle:commands does nothing in a headless context", async () => {
921
+ const { pi, commands, sentMessages } = fakePi();
922
+ gentleShell(pi, {});
923
+ const { ctx, ui } = fakeContext({ hasUI: false });
924
+ await commands.get("gentle:commands")!.handler("", ctx);
925
+ assert.equal(ui.overlay, undefined);
926
+ assert.deepEqual(sentMessages, []);
927
+ });
928
+
929
+ test("the alt+k shortcut opens the same command palette as the command", async () => {
930
+ const { pi, shortcuts } = fakePi();
931
+ gentleShell(pi, {});
932
+ const { ctx, ui, overlayReady } = fakeContext();
933
+ const shortcut = shortcuts.get("alt+k");
934
+ assert.ok(shortcut, "alt+k not registered");
935
+ const opened = shortcut.handler(ctx);
936
+ await overlayReady;
937
+ assert.match(ui.overlayView!.render(100).join("\n"), /Assign models and effort/);
938
+ ui.overlayView!.handleInput("\x1b");
939
+ await opened;
940
+ });
941
+
942
+ test("resolving the overlay through closeOverlay sends nothing and does not throw", async () => {
943
+ const { pi, commands, sentMessages } = fakePi();
944
+ gentleShell(pi, {});
945
+ const { ctx, ui, overlayReady } = fakeContext();
946
+ const opened = commands.get("gentle:commands")!.handler("", ctx);
947
+ await overlayReady;
948
+ ui.closeOverlay?.();
949
+ await opened;
950
+ assert.deepEqual(sentMessages, []);
951
+ });