From 830feb5e7150ae144f1e572cea4f0d8e6747cf63 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 20 Sep 2026 17:07:09 +0200 Subject: [PATCH] feat(shell): open selectable menus for animations and background-subagents commands Invoking /gentle:animations or /gentle:background-subagents with no argument now opens a native selectable menu (ctx.ui.select) instead of requiring the sub-action to be typed by hand. The animations menu lists quality/performance/potato/status with the current policy in the title; the background-subagents menu lists status/enable/disable. A dismissed menu does nothing; typed sub-actions, invalid-argument warnings, and the headless status fallback keep their existing behavior. Closes #1268 --- extensions/gentle-ai.ts | 11 ++++- extensions/gentle-shell.ts | 14 +++++- tests/background-subagents.test.ts | 68 ++++++++++++++++++++++++++++++ tests/gentle-shell.test.ts | 33 ++++++++++++++- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 6983180bc..f5e3be151 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -9706,9 +9706,16 @@ function createGentleAiExtensionForTesting( // background subagents may be launched at all, so nothing in Pi may write // it. The only writer is this handler, reached only by explicit invocation. pi.registerCommand("gentle:background-subagents", { - description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", + description: "Show or set the managed background-subagents policy; no argument opens a selectable menu (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", + // No argument opens a selectable menu when an interactive UI is present; + // headless callers and fakes without ui.select keep the status fallback. handler: async (args, ctx) => { - const subAction = args.trim().length === 0 ? "status" : args.trim(); + let subAction = args.trim().length === 0 ? "status" : args.trim(); + if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") { + const selected = await ctx.ui.select("Background subagents policy", ["status", "enable", "disable"]); + if (selected === undefined) return; + subAction = selected; + } if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") { ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning"); return; diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index b4ad6e28c..31a849a62 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -990,9 +990,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }); } pi.registerCommand("gentle:animations", { - description: "Show or set global animations (status|quality|performance|potato); no argument reports status.", + description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).", + // No argument opens a selectable menu when an interactive UI is present; + // headless callers and fakes without ui.select keep the status fallback. handler: async (args, ctx) => { - const action = args.trim() || "status"; + let action = args.trim() || "status"; + if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") { + const selected = await ctx.ui.select( + `Gentle animations (current: ${animationPolicy})`, + ["quality", "performance", "potato", "status"], + ); + if (selected === undefined) return; + action = selected; + } if (action !== "status" && action !== "quality" && action !== "performance" && action !== "potato") { ctx.ui.notify("Use /gentle:animations status|quality|performance|potato.", "warning"); return; diff --git a/tests/background-subagents.test.ts b/tests/background-subagents.test.ts index c888906f8..7b79f4e68 100644 --- a/tests/background-subagents.test.ts +++ b/tests/background-subagents.test.ts @@ -530,6 +530,28 @@ function notifyContext( } as unknown as ExtensionContext; } +/** + * The same minimal context as `notifyContext`, but interactive: `hasUI` is true + * and `ui.select` answers the command's no-argument menu. `notifyContext` stays + * `hasUI: false` so every existing sub-action test keeps the non-menu path. + */ +function selectingContext( + cwd: string, + notices: Array<{ message: string; type?: string }>, + select: (title: string, options: string[]) => Promise, +): ExtensionContext { + return { + cwd, + hasUI: true, + ui: { + notify: (message: string, type?: string) => { + notices.push({ message, type }); + }, + select, + }, + } as unknown as ExtensionContext; +} + /** Point the command's global config home at a scratch dir, never at ~/.pi. */ function scopedEnv(t: TestContext, values: Record): void { const previous = new Map(); @@ -590,6 +612,52 @@ test("no argument reports the effective policy, the deciding default, and the ca ); }); +test("no argument opens a selectable menu and applies the chosen sub-action", async (t) => { + const cwd = makeScratch("gp-bg-cmd-select-"); + const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai"); + const globalFile = join(configHome, "background-subagents.json"); + scopedEnv(t, { + GENTLE_PI_CONFIG_HOME: configHome, + GENTLE_PI_BACKGROUND_SUBAGENTS: undefined, + }); + const command = registeredCommands().get("gentle:background-subagents"); + assert.ok(command, "gentle:background-subagents must be registered"); + + // A dismissed menu (undefined selection) reports nothing and writes nothing. + // It runs first so the untouched config home can prove the no-write: a second + // scopedEnv save point in one test would restore out of order at teardown. + const dismissed: Array<{ message: string; type?: string }> = []; + await command!.handler("", selectingContext(cwd, dismissed, async () => undefined)); + assert.equal(dismissed.length, 0, "a dismissed menu reports nothing"); + assert.equal(existsSync(globalFile), false, "a dismissed menu writes no file"); + + // Choosing "enable" writes the global file and reports exactly once, in the + // same shape the direct `enable` sub-action already reports. + const notices: Array<{ message: string; type?: string }> = []; + await command!.handler( + "", + selectingContext(cwd, notices, async (title, options) => { + assert.equal(title, "Background subagents policy"); + assert.deepEqual(options, ["status", "enable", "disable"]); + return "enable"; + }), + ); + assert.equal(notices.length, 1, "one invocation reports exactly once"); + assert.equal(notices[0]!.type, "info"); + assert.equal( + notices[0]!.message, + [ + `background subagents: on (decided by global file ${globalFile}; capability: absent)`, + `Wrote on to the global file ${globalFile}.`, + "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.", + ].join("\n"), + ); + assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), { + schema: "gentle-pi.background-subagents/v1", + policy: "on", + }); +}); + test("status names the project file that decided and the global file it shadows", async (t) => { const cwd = makeScratch("gp-bg-cmd-project-"); const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai"); diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 8f8c36aed..e6a37b440 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -129,7 +129,7 @@ async function fire(handlers: Map } { +function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise } { const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined }; let resolveOverlay: () => void; const overlayReady = new Promise((resolve) => { resolveOverlay = resolve; }); @@ -150,6 +150,9 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }), ui: { theme: plainTheme, + // Added only when requested: an absent select keeps the no-menu fallback + // that every pre-existing test relies on. + ...(options.select ? { select: options.select } : {}), setFooter(factory: unknown) { ui.footerFactory = factory; }, @@ -560,6 +563,34 @@ test("animations status attributes malformed files and reports a failed write", assert.match(ui.notices.at(-1)!, /EISDIR|ENOTEMPTY|EPERM/); }); +test("animations with no argument opens a selectable menu and applies the chosen policy", async (t) => { + const configHome = scopedDoubleEscCancelConfigHome(t); + const path = join(configHome, "animations.json"); + const { pi, commands } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }); + // No editor/prompt is installed: the handler's `prompt?.setAnimationPolicy` + // optional chain must tolerate the interactive menu without one. + const chosen = fakeContext({ + select: async (title, options) => { + assert.match(title, /Gentle animations/); + assert.deepEqual(options, ["quality", "performance", "potato", "status"]); + return "potato"; + }, + }); + await commands.get("gentle:animations")!.handler("", chosen.ctx); + assert.equal(JSON.parse(readFileSync(path, "utf8")).policy, "potato"); + assert.match(chosen.ui.notices.at(-1)!, /animations: potato/); + + // A dismissed menu (undefined selection) reports nothing and writes nothing. + const dismissHome = scopedDoubleEscCancelConfigHome(t); + const { pi: dismissPi, commands: dismissCommands } = fakePi(); + gentleShell(dismissPi, { GENTLE_PI_CONFIG_HOME: dismissHome }); + const dismissed = fakeContext({ select: async () => undefined }); + await dismissCommands.get("gentle:animations")!.handler("", dismissed.ctx); + assert.equal(dismissed.ui.notices.length, 0); + assert.equal(existsSync(join(dismissHome, "animations.json")), false); +}); + test("prompt uses the compact banner cadence and releases its unref timer at settlement", (t) => { const configHome = scopedDoubleEscCancelConfigHome(t); writeFileSync(join(configHome, "animations.json"), '{"schema":"gentle-pi.animations/v1","policy":"quality"}');