Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 12 additions & 2 deletions extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions tests/background-subagents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>,
): 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<string, string | undefined>): void {
const previous = new Map<string, string | undefined>();
Expand Down Expand Up @@ -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");
Expand Down
33 changes: 32 additions & 1 deletion tests/gentle-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ async function fire(handlers: Map<string, Array<(event: unknown, ctx: ExtensionC
for (const handler of handlers.get(event) ?? []) await handler({}, ctx);
}

function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise<void> } {
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> } {
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<void>((resolve) => { resolveOverlay = resolve; });
Expand All @@ -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;
},
Expand Down Expand Up @@ -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"}');
Expand Down
Loading