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
@@ -0,0 +1,435 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import askUserQuestion from "../extensions/ask-user-question.ts";
4
+
5
+ /** Plain theme fake: identity styling keeps rendered assertions readable. */
6
+ interface Theme {
7
+ fg(color: string, text: string): string;
8
+ bg?(color: string, text: string): string;
9
+ bold?(text: string): string;
10
+ }
11
+
12
+ const theme: Theme = {
13
+ fg: (_color: string, text: string) => text,
14
+ bg: (_color: string, text: string) => text,
15
+ bold: (text: string) => text,
16
+ };
17
+
18
+ interface Renderable {
19
+ render(width: number): string[];
20
+ handleInput?(data: string): void;
21
+ }
22
+
23
+ type CustomFactory = (
24
+ tui: { requestRender(): void },
25
+ theme: Theme,
26
+ keybindings: unknown,
27
+ done: (value: unknown) => void,
28
+ ) => Renderable;
29
+
30
+ interface ToolResult {
31
+ content: Array<{ type: string; text: string }>;
32
+ details: Record<string, unknown>;
33
+ }
34
+
35
+ interface RegisteredTool {
36
+ name: string;
37
+ label: string;
38
+ description?: string;
39
+ renderShell?: string;
40
+ promptGuidelines?: string[];
41
+ parameters: {
42
+ additionalProperties?: boolean;
43
+ properties?: {
44
+ questions?: {
45
+ minItems?: number;
46
+ maxItems?: number;
47
+ items?: { additionalProperties?: boolean; properties?: Record<string, unknown> };
48
+ };
49
+ };
50
+ };
51
+ executionMode?: string;
52
+ execute(...args: unknown[]): Promise<ToolResult>;
53
+ renderCall(args: unknown, theme: Theme): Renderable;
54
+ renderResult(result: unknown, options: unknown, theme: Theme): Renderable;
55
+ }
56
+
57
+ interface LifecycleEvent {
58
+ channel: string;
59
+ data: { active: boolean };
60
+ }
61
+
62
+ /** One fake extension slot: Pi keys tools per extension by name (`loader.js:240`). */
63
+ interface ExtensionSlot {
64
+ path: string;
65
+ tools: Map<string, RegisteredTool>;
66
+ }
67
+
68
+ const OURS_PATH = "gentle-pi/extensions/ask-user-question.ts";
69
+
70
+ function registerQuestionTool(slot?: ExtensionSlot): { tool: RegisteredTool; slot: ExtensionSlot; emitted: LifecycleEvent[] } {
71
+ const target: ExtensionSlot = slot ?? { path: OURS_PATH, tools: new Map() };
72
+ const emitted: LifecycleEvent[] = [];
73
+ const pi = {
74
+ registerTool(tool: RegisteredTool) {
75
+ target.tools.set(tool.name, tool);
76
+ },
77
+ events: {
78
+ emit(channel: string, data: { active: boolean }) {
79
+ emitted.push({ channel, data });
80
+ },
81
+ },
82
+ };
83
+ askUserQuestion(pi as never);
84
+ const tool = target.tools.get("ask_user_question");
85
+ if (!tool) throw new Error("ask_user_question must register");
86
+ return { tool, slot: target, emitted };
87
+ }
88
+
89
+ function tuiContext(inputs: readonly string[], rendered?: { value: string }) {
90
+ return {
91
+ mode: "tui",
92
+ ui: {
93
+ custom: async (factory: CustomFactory) => {
94
+ let result: unknown;
95
+ const component = factory({ requestRender() {} }, theme, {}, (value) => {
96
+ result = value;
97
+ });
98
+ if (rendered) rendered.value = component.render(100).join("\n");
99
+ for (const input of inputs) component.handleInput?.(input);
100
+ return result;
101
+ },
102
+ },
103
+ };
104
+ }
105
+
106
+ function run(tool: RegisteredTool, params: unknown, ctx: unknown): Promise<ToolResult> {
107
+ return tool.execute("call", params, new AbortController().signal, undefined, ctx);
108
+ }
109
+
110
+ const option = (label: string, description = `${label} description`, preview?: string) =>
111
+ preview === undefined ? { label, description } : { label, description, preview };
112
+
113
+ const single = () => [
114
+ { question: "Proceed?", header: "Proceed", options: [option("Alpha"), option("Beta")] },
115
+ ];
116
+
117
+ test("ask_user_question opts out of the painted shell and pins the schema limits", () => {
118
+ const { tool } = registerQuestionTool();
119
+ const questions = tool.parameters.properties?.questions;
120
+
121
+ assert.equal(tool.renderShell, "self");
122
+ assert.equal(tool.name, "ask_user_question");
123
+ assert.equal(tool.label, "Ask User Question");
124
+ assert.equal(tool.executionMode, "sequential");
125
+ assert.equal(tool.parameters.additionalProperties, false);
126
+ assert.equal(questions?.minItems, 1);
127
+ assert.equal(questions?.maxItems, 4);
128
+ assert.equal(questions?.items?.additionalProperties, false);
129
+ assert.deepEqual(Object.keys(questions?.items?.properties ?? {}).sort(), ["header", "multiSelect", "options", "question"]);
130
+ });
131
+
132
+ test("ask_user_question guides the model toward the supported contract", () => {
133
+ const { tool } = registerQuestionTool();
134
+ const guidelines = (tool.promptGuidelines ?? []).join(" ");
135
+
136
+ assert.match(tool.description ?? "", /one to four structured questions/i);
137
+ assert.match(guidelines, /at most 16 characters/);
138
+ assert.match(guidelines, /at most 60 characters/);
139
+ assert.match(guidelines, /preview/);
140
+ assert.match(guidelines, /multiSelect/);
141
+ assert.match(guidelines, /Type something\./);
142
+ assert.match(guidelines, /Never use this tool for decisions that must not be delegated to the user\./);
143
+ });
144
+
145
+ test("ask_user_question rejects invalid parameters before mounting any UI", async () => {
146
+ const { tool, emitted } = registerQuestionTool();
147
+ let customCalls = 0;
148
+ const ctx = {
149
+ mode: "tui",
150
+ ui: {
151
+ custom: async () => {
152
+ customCalls++;
153
+ return undefined;
154
+ },
155
+ },
156
+ };
157
+
158
+ const result = await run(tool, { questions: [] }, ctx);
159
+
160
+ assert.equal(result.content[0]?.text, "Invalid questionnaire: At least one question is required.");
161
+ assert.equal(result.details.errorKind, "no_questions");
162
+ assert.deepEqual(result.details.error, { code: "no_questions", message: "At least one question is required." });
163
+ assert.equal(customCalls, 0, "invalid input never reaches ctx.ui.custom");
164
+ assert.deepEqual(emitted, [], "invalid input emits no lifecycle event");
165
+ });
166
+
167
+ test("ask_user_question stays unavailable outside the interactive TUI", async () => {
168
+ const { tool, emitted } = registerQuestionTool();
169
+ let customCalls = 0;
170
+ const ctx = {
171
+ mode: "print",
172
+ ui: {
173
+ custom: async () => {
174
+ customCalls++;
175
+ return undefined;
176
+ },
177
+ },
178
+ };
179
+
180
+ const result = await run(tool, { questions: single() }, ctx);
181
+
182
+ assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/);
183
+ assert.equal(result.details.errorKind, "unavailable_outside_tui");
184
+ assert.equal(customCalls, 0);
185
+ assert.deepEqual(emitted, []);
186
+ });
187
+
188
+ test("ask_user_question commits a single-select answer end-to-end", async () => {
189
+ const { tool, emitted } = registerQuestionTool();
190
+ const rendered = { value: "" };
191
+
192
+ const result = await run(tool, { questions: single() }, tuiContext(["\r"], rendered));
193
+
194
+ assert.match(rendered.value, /\[1\/1\]/);
195
+ assert.match(rendered.value, /▸ Proceed/);
196
+ assert.match(rendered.value, /Alpha/);
197
+ assert.equal(result.content[0]?.text, "1. Proceed? — Alpha");
198
+ assert.deepEqual(result.details.answers, [
199
+ { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" },
200
+ ]);
201
+ assert.deepEqual(emitted, [
202
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
203
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
204
+ ]);
205
+ });
206
+
207
+ test("ask_user_question mounts through ctx.ui.custom without an overlay option", async () => {
208
+ const { tool } = registerQuestionTool();
209
+ let customArgCount = -1;
210
+ const ctx = {
211
+ mode: "tui",
212
+ ui: {
213
+ custom: async (...args: unknown[]) => {
214
+ customArgCount = args.length;
215
+ const factory = args[0] as CustomFactory;
216
+ let result: unknown;
217
+ const component = factory({ requestRender() {} }, theme, {}, (value) => {
218
+ result = value;
219
+ });
220
+ component.handleInput?.("\r");
221
+ return result;
222
+ },
223
+ },
224
+ };
225
+
226
+ await run(tool, { questions: single() }, ctx);
227
+
228
+ assert.equal(customArgCount, 1, "a dock swap passes the factory only; no overlay options");
229
+ });
230
+
231
+ test("ask_user_question mounts exactly one active question and switches with Tab", async () => {
232
+ const { tool } = registerQuestionTool();
233
+ const questions = [
234
+ { question: "First?", header: "First", options: [option("Alpha"), option("Beta")] },
235
+ { question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] },
236
+ ];
237
+ let component: Renderable | undefined;
238
+ const pending = run(tool, { questions }, {
239
+ mode: "tui",
240
+ ui: {
241
+ custom: (factory: CustomFactory) => new Promise((resolve) => {
242
+ component = factory({ requestRender() {} }, theme, {}, resolve);
243
+ }),
244
+ },
245
+ });
246
+
247
+ assert.ok(component, "the tool mounts the questionnaire component");
248
+ const first = component!.render(100).join("\n");
249
+ assert.match(first, /\[1\/2\]/);
250
+ assert.match(first, /▸ First/);
251
+ assert.match(first, /❯ Alpha/);
252
+ assert.doesNotMatch(first, /Gamma/);
253
+ assert.doesNotMatch(first, /Second\?/);
254
+
255
+ component!.handleInput?.("\t");
256
+ const second = component!.render(100).join("\n");
257
+ assert.match(second, /\[2\/2\]/);
258
+ assert.match(second, /▸ Second/);
259
+ assert.match(second, /❯ Gamma/);
260
+ assert.doesNotMatch(second, /First\?/);
261
+
262
+ component!.handleInput?.("\x1b"); // cancel to settle the tool
263
+ await pending;
264
+ });
265
+
266
+ test("ask_user_question echoes an option preview beside the answer", async () => {
267
+ const { tool } = registerQuestionTool();
268
+ const questions = [
269
+ { question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] },
270
+ ];
271
+
272
+ const result = await run(tool, { questions }, tuiContext(["\r"]));
273
+
274
+ assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A");
275
+ assert.deepEqual(result.details.answers, [
276
+ { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" },
277
+ ]);
278
+ });
279
+
280
+ test("ask_user_question commits a multiSelect answer with every toggled option", async () => {
281
+ const { tool } = registerQuestionTool();
282
+ const questions = [
283
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
284
+ ];
285
+
286
+ const result = await run(tool, { questions }, tuiContext([" ", "\r"]));
287
+
288
+ assert.equal(result.content[0]?.text, "1. Pick? — selected: One");
289
+ assert.deepEqual(result.details.answers, [
290
+ { questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
291
+ ]);
292
+ });
293
+
294
+ test("ask_user_question commits a free-text custom answer", async () => {
295
+ const { tool } = registerQuestionTool();
296
+
297
+ const result = await run(
298
+ tool,
299
+ { questions: single() },
300
+ tuiContext(["\x1b[B", "\x1b[B", "\r", "custom text", "\r"]),
301
+ );
302
+
303
+ assert.equal(result.content[0]?.text, "1. Proceed? — (custom) custom text");
304
+ assert.deepEqual(result.details.answers, [
305
+ { questionIndex: 0, question: "Proceed?", kind: "custom", answer: "custom text" },
306
+ ]);
307
+ });
308
+
309
+ test("ask_user_question keeps toggled options in a multiSelect custom answer", async () => {
310
+ const { tool } = registerQuestionTool();
311
+ const questions = [
312
+ { question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
313
+ ];
314
+
315
+ // Toggle One, move to the custom row, open the editor, type, and submit.
316
+ const result = await run(
317
+ tool,
318
+ { questions },
319
+ tuiContext([" ", "\x1b[B", "\x1b[B", "\r", "free note", "\r"]),
320
+ );
321
+
322
+ assert.equal(result.content[0]?.text, "1. Pick? — (custom) free note — selected: One");
323
+ assert.deepEqual(result.details.answers, [
324
+ { questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] },
325
+ ]);
326
+
327
+ const rendered = tool.renderResult(result, { expanded: false }, theme).render(200).join("\n");
328
+ assert.equal(rendered.trimEnd(), "✓ Pick? — (custom) free note — selected: One");
329
+ });
330
+
331
+ test("ask_user_question reports cancellation without answers", async () => {
332
+ const { tool, emitted } = registerQuestionTool();
333
+
334
+ const result = await run(tool, { questions: single() }, tuiContext(["\x1b"]));
335
+
336
+ assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
337
+ assert.deepEqual(result.details, { cancelled: true });
338
+ assert.deepEqual(emitted, [
339
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
340
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
341
+ ]);
342
+ });
343
+
344
+ test("ask_user_question settles its lifecycle after a custom UI error", async () => {
345
+ const { tool, emitted } = registerQuestionTool();
346
+ const failure = new Error("custom UI failed");
347
+
348
+ await assert.rejects(
349
+ () => run(tool, { questions: single() }, { mode: "tui", ui: { custom: async () => { throw failure; } } }),
350
+ (error: unknown) => error === failure,
351
+ );
352
+
353
+ assert.deepEqual(emitted, [
354
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
355
+ { channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
356
+ ]);
357
+ });
358
+
359
+ test("ask_user_question owns an exclusive tool name across extensions", () => {
360
+ // Live-verified against the installed Pi runtime: tool names are exclusive
361
+ // across extensions. Loading two extensions that register
362
+ // `ask_user_question` aborts the whole load with a hard error
363
+ // (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
364
+ // exits non-zero) -- there is no precedence, override, or silent shadowing.
365
+ // This fake registry is a per-extension Map and cannot reproduce Pi's
366
+ // cross-extension load error, so it pins the part it can: our single
367
+ // registration owns the name within its own extension, and the runtime, not
368
+ // resource order, enforces exclusivity outside it. The competing
369
+ // `@juicesharp/rpiv-ask-user-question` package must be removed from the
370
+ // user's settings before this extension can load.
371
+ const ours: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
372
+ const registration = registerQuestionTool(ours);
373
+
374
+ assert.equal(ours.tools.get("ask_user_question"), registration.tool, "the first-party extension owns its name");
375
+ assert.equal(registration.tool.name, "ask_user_question");
376
+ assert.equal(registration.tool.label, "Ask User Question");
377
+ });
378
+
379
+ test("re-registering inside one extension overwrites its own tool entry", () => {
380
+ const slot: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
381
+ registerQuestionTool(slot);
382
+ const first = slot.tools.get("ask_user_question");
383
+ registerQuestionTool(slot);
384
+ const second = slot.tools.get("ask_user_question");
385
+
386
+ assert.equal(slot.tools.size, 1, "the extension map is keyed by tool name");
387
+ assert.notEqual(first, second, "a later registration replaces the same-name entry");
388
+ });
389
+
390
+ test("ask_user_question renderCall summarizes the questions and option labels", () => {
391
+ const { tool } = registerQuestionTool();
392
+ const rendered = tool.renderCall({ questions: single() }, theme).render(100).join("\n");
393
+
394
+ assert.match(rendered, /ask_user_question/);
395
+ assert.match(rendered, /1\. Proceed \(Alpha, Beta\)/);
396
+ });
397
+
398
+ test("ask_user_question renderCall truncates an oversized summary", () => {
399
+ const { tool } = registerQuestionTool();
400
+ const labels = [option("a".repeat(60)), option("b".repeat(60)), option("c".repeat(60))];
401
+ const rendered = tool.renderCall(
402
+ { questions: [{ question: "Long?", header: "Long", options: labels }] },
403
+ theme,
404
+ ).render(200).join("\n");
405
+
406
+ assert.match(rendered, /…\s*$/);
407
+ });
408
+
409
+ test("ask_user_question renderResult renders answered and cancelled rows", () => {
410
+ const { tool } = registerQuestionTool();
411
+ const answered = tool.renderResult(
412
+ {
413
+ content: [],
414
+ details: {
415
+ answers: [
416
+ { questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" },
417
+ { questionIndex: 1, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
418
+ { questionIndex: 2, question: "Explain?", kind: "custom", answer: "because" },
419
+ ],
420
+ },
421
+ },
422
+ { expanded: false },
423
+ theme,
424
+ ).render(200).join("\n");
425
+ assert.match(answered, /✓ Proceed\? — Beta/);
426
+ assert.match(answered, /✓ Pick\? — One, Two/);
427
+ assert.match(answered, /✓ Explain\? — \(custom\) because/);
428
+
429
+ const cancelled = tool.renderResult(
430
+ { content: [], details: { cancelled: true } },
431
+ { expanded: false },
432
+ theme,
433
+ ).render(200).join("\n");
434
+ assert.match(cancelled, /Cancelled/);
435
+ });
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+ import { installBackgroundCacheWarming, type WarmingState } from "../lib/background-cache-warming.ts";
5
+
6
+ test("native warming decisions use only current owned live background work", () => {
7
+ let handler: Parameters<Parameters<typeof installBackgroundCacheWarming>[0]["on"]>[1];
8
+ let state: WarmingState = { sessionId: "parent", ownedTaskIds: new Set(["task"]), tasks: [
9
+ { id: "task", parentSessionId: "parent", mode: "background", status: "queued" },
10
+ ] };
11
+ installBackgroundCacheWarming({ on(event, callback) {
12
+ assert.equal(event, "cache_warming_decision");
13
+ handler = callback;
14
+ } }, () => state);
15
+ const candidate = { warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" as const };
16
+ assert.deepEqual(handler!(candidate), { action: "warm" });
17
+ state.tasks[0].status = "running";
18
+ assert.deepEqual(handler!(candidate), { action: "warm" });
19
+ for (const status of ["waiting", "completed", "failed", "cancelled", "timed_out"] as const) {
20
+ state.tasks[0].status = status;
21
+ assert.equal(handler!(candidate), undefined, status);
22
+ }
23
+ state.tasks[0].status = "running";
24
+ for (const patch of [
25
+ { sessionId: undefined }, { sessionId: "other" }, { ownedTaskIds: new Set<string>() },
26
+ { tasks: [] }, { tasks: [{ ...state.tasks[0], mode: "task" }] },
27
+ ]) {
28
+ const original = state;
29
+ state = { ...state, ...patch };
30
+ assert.equal(handler!(candidate), undefined);
31
+ state = original;
32
+ }
33
+ for (const warmCost of [0.06, 1, NaN, Infinity, -1]) {
34
+ assert.deepEqual(handler!({ ...candidate, warmCost }), { action: "stop" });
35
+ }
36
+ for (const missCost of [0, -1, NaN, Infinity]) {
37
+ assert.deepEqual(handler!({ ...candidate, missCost }), { action: "stop" });
38
+ }
39
+ assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.05 }), { action: "warm" });
40
+ assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.049 }), { action: "stop" });
41
+ state.tasks = [];
42
+ assert.equal(handler!({ ...candidate, action: "warm" }), undefined, "ordinary idle keeps native action");
43
+ });
44
+
45
+ // Supplemental architecture guard; the extension lifecycle is exercised in gentle-agents.test.ts.
46
+ test("warming helper has no maintenance capabilities", () => {
47
+ const helper = readFileSync(new URL("../lib/background-cache-warming.ts", import.meta.url), "utf8");
48
+ assert.doesNotMatch(helper, /setTimeout|setInterval|sendMessage|sendUserMessage|subagent_|runner\./);
49
+ });
50
+
51
+ test("background prompt prohibits cache polling and documents explicit native idle opt-in", () => {
52
+ const prompt = readFileSync(new URL("../assets/orchestrator-delegation.md", import.meta.url), "utf8");
53
+ assert.match(prompt, /completion or cache maintenance/);
54
+ assert.match(prompt, /user-requested inspection, relevant scope change, input request, or suspected abnormal behavior/);
55
+ assert.match(prompt, /Never relaunch equivalent work merely because it is queued or running/);
56
+ const docs = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
57
+ assert.match(docs, /"cacheWarming": "idle"/);
58
+ assert.match(docs, /\$0\.05/);
59
+ assert.match(docs, /"streaming".*no idle decision/s);
60
+ });
@@ -530,6 +530,28 @@ function notifyContext(
530
530
  } as unknown as ExtensionContext;
531
531
  }
532
532
 
533
+ /**
534
+ * The same minimal context as `notifyContext`, but interactive: `hasUI` is true
535
+ * and `ui.select` answers the command's no-argument menu. `notifyContext` stays
536
+ * `hasUI: false` so every existing sub-action test keeps the non-menu path.
537
+ */
538
+ function selectingContext(
539
+ cwd: string,
540
+ notices: Array<{ message: string; type?: string }>,
541
+ select: (title: string, options: string[]) => Promise<string | undefined>,
542
+ ): ExtensionContext {
543
+ return {
544
+ cwd,
545
+ hasUI: true,
546
+ ui: {
547
+ notify: (message: string, type?: string) => {
548
+ notices.push({ message, type });
549
+ },
550
+ select,
551
+ },
552
+ } as unknown as ExtensionContext;
553
+ }
554
+
533
555
  /** Point the command's global config home at a scratch dir, never at ~/.pi. */
534
556
  function scopedEnv(t: TestContext, values: Record<string, string | undefined>): void {
535
557
  const previous = new Map<string, string | undefined>();
@@ -590,6 +612,52 @@ test("no argument reports the effective policy, the deciding default, and the ca
590
612
  );
591
613
  });
592
614
 
615
+ test("no argument opens a selectable menu and applies the chosen sub-action", async (t) => {
616
+ const cwd = makeScratch("gp-bg-cmd-select-");
617
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
618
+ const globalFile = join(configHome, "background-subagents.json");
619
+ scopedEnv(t, {
620
+ GENTLE_PI_CONFIG_HOME: configHome,
621
+ GENTLE_PI_BACKGROUND_SUBAGENTS: undefined,
622
+ });
623
+ const command = registeredCommands().get("gentle:background-subagents");
624
+ assert.ok(command, "gentle:background-subagents must be registered");
625
+
626
+ // A dismissed menu (undefined selection) reports nothing and writes nothing.
627
+ // It runs first so the untouched config home can prove the no-write: a second
628
+ // scopedEnv save point in one test would restore out of order at teardown.
629
+ const dismissed: Array<{ message: string; type?: string }> = [];
630
+ await command!.handler("", selectingContext(cwd, dismissed, async () => undefined));
631
+ assert.equal(dismissed.length, 0, "a dismissed menu reports nothing");
632
+ assert.equal(existsSync(globalFile), false, "a dismissed menu writes no file");
633
+
634
+ // Choosing "enable" writes the global file and reports exactly once, in the
635
+ // same shape the direct `enable` sub-action already reports.
636
+ const notices: Array<{ message: string; type?: string }> = [];
637
+ await command!.handler(
638
+ "",
639
+ selectingContext(cwd, notices, async (title, options) => {
640
+ assert.equal(title, "Background subagents policy");
641
+ assert.deepEqual(options, ["status", "enable", "disable"]);
642
+ return "enable";
643
+ }),
644
+ );
645
+ assert.equal(notices.length, 1, "one invocation reports exactly once");
646
+ assert.equal(notices[0]!.type, "info");
647
+ assert.equal(
648
+ notices[0]!.message,
649
+ [
650
+ `background subagents: on (decided by global file ${globalFile}; capability: absent)`,
651
+ `Wrote on to the global file ${globalFile}.`,
652
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
653
+ ].join("\n"),
654
+ );
655
+ assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
656
+ schema: "gentle-pi.background-subagents/v1",
657
+ policy: "on",
658
+ });
659
+ });
660
+
593
661
  test("status names the project file that decided and the global file it shadows", async (t) => {
594
662
  const cwd = makeScratch("gp-bg-cmd-project-");
595
663
  const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
@@ -32,6 +32,14 @@ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPa
32
32
  return { palette, results };
33
33
  }
34
34
 
35
+ test("animations is discoverable under Configuration with its live description", () => {
36
+ const groups = buildCommandPaletteGroups([{ name: "gentle:animations", description: "status|quality|performance|potato" }], {});
37
+ assert.equal(groups[0]?.title, "Configuration");
38
+ assert.equal(groups[0]?.items[0]?.command, "gentle:animations");
39
+ assert.match(groups[0]?.items[0]?.label ?? "", /[Aa]nimation/);
40
+ assert.equal(groups[0]?.items[0]?.description, "status|quality|performance|potato");
41
+ });
42
+
35
43
  // --- rankPaletteGroups -------------------------------------------------------
36
44
 
37
45
  test("rankPaletteGroups ranks label-prefix, label-contains, label-subsequence, command-contains, then description-contains", () => {
@@ -336,6 +344,8 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
336
344
  "gentle:persona",
337
345
  "gentle:review-mode",
338
346
  "gentle:background-subagents",
347
+ "gentle:double-esc-cancel",
348
+ "gentle:animations",
339
349
  "gentle:telemetry",
340
350
  "gentle:banner",
341
351
  "gentle:banner-color",