refactor: replace serial prefs wizard with categorized menu (#623)

* refactor: replace serial prefs wizard with categorized menu

The /gsd prefs wizard previously dumped 20+ prompts in sequence, which
was overwhelming. This refactors it into a category picker loop where
users select from 7 categories (Models, Timeouts, Git, Skills, Budget,
Notifications, Advanced), configure only what they need, and return to
the menu with updated summaries showing current values at a glance.

- Extract 7 category functions from monolithic handlePrefsWizard
- Add buildCategorySummaries() for current-value display in menu
- Category loop with Save & Exit / Escape to serialize and write
- No logic changes to individual prompts — pure structural refactor

* fix: narrow ctx.ui.select return type for TypeScript strict mode

ctx.ui.select returns string | string[], so startsWith is not available
without narrowing. Extract to string with typeof guard before dispatching.
This commit is contained in:
Jeremy McSpadden 2026-03-16 10:01:27 -05:00 committed by GitHub
parent 369bd8aeb9
commit 2fd4a1da60

View file

@ -520,17 +520,87 @@ async function handleDoctor(args: string, ctx: ExtensionCommandContext, pi: Exte
// ─── Preferences Wizard ───────────────────────────────────────────────────────
async function handlePrefsWizard(
ctx: ExtensionCommandContext,
scope: "global" | "project",
): Promise<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
/** Build short summary strings for each preference category. */
function buildCategorySummaries(prefs: Record<string, unknown>): Record<string, string> {
// Models
const models = prefs.models as Record<string, string> | undefined;
let modelsSummary = "(not configured)";
if (models && Object.keys(models).length > 0) {
const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${model}`);
modelsSummary = parts.join(", ");
}
ctx.ui.notify(`GSD preferences wizard (${scope}) — press Escape at any prompt to skip it.`, "info");
// Timeouts
const autoSup = prefs.auto_supervisor as Record<string, unknown> | undefined;
let timeoutsSummary = "(defaults)";
if (autoSup && Object.keys(autoSup).length > 0) {
const soft = autoSup.soft_timeout_minutes ?? "20";
const idle = autoSup.idle_timeout_minutes ?? "10";
const hard = autoSup.hard_timeout_minutes ?? "30";
timeoutsSummary = `soft: ${soft}m, idle: ${idle}m, hard: ${hard}m`;
}
// ─── Models ──────────────────────────────────────────────────────────────
// Git
const git = prefs.git as Record<string, unknown> | undefined;
let gitSummary = "(defaults)";
if (git && Object.keys(git).length > 0) {
const branch = git.main_branch ?? "main";
const push = git.auto_push ? "on" : "off";
gitSummary = `main: ${branch}, push: ${push}`;
}
// Skills
const discovery = prefs.skill_discovery as string | undefined;
const uat = prefs.uat_dispatch;
let skillsSummary = "(not configured)";
if (discovery || uat !== undefined) {
const parts: string[] = [];
if (discovery) parts.push(`discovery: ${discovery}`);
if (uat !== undefined) parts.push(`uat: ${uat}`);
skillsSummary = parts.join(", ");
}
// Budget
const ceiling = prefs.budget_ceiling;
const enforcement = prefs.budget_enforcement as string | undefined;
let budgetSummary = "(no limit)";
if (ceiling !== undefined) {
budgetSummary = `$${ceiling}`;
if (enforcement) budgetSummary += ` / ${enforcement}`;
} else if (enforcement) {
budgetSummary = enforcement;
}
// Notifications
const notif = prefs.notifications as Record<string, boolean> | undefined;
let notifSummary = "(defaults)";
if (notif && Object.keys(notif).length > 0) {
const allKeys = ["enabled", "on_complete", "on_error", "on_budget", "on_milestone", "on_attention"];
const enabledCount = allKeys.filter(k => notif[k] !== false).length;
notifSummary = `${enabledCount}/${allKeys.length} enabled`;
}
// Advanced
const uniqueIds = prefs.unique_milestone_ids;
let advancedSummary = "(defaults)";
if (uniqueIds !== undefined) {
advancedSummary = `unique IDs: ${uniqueIds ? "on" : "off"}`;
}
return {
models: modelsSummary,
timeouts: timeoutsSummary,
git: gitSummary,
skills: skillsSummary,
budget: budgetSummary,
notifications: notifSummary,
advanced: advancedSummary,
};
}
// ─── Category configuration functions ────────────────────────────────────────
async function configureModels(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const modelPhases = ["research", "planning", "execution", "completion"] as const;
const models: Record<string, string> = (prefs.models as Record<string, string>) ?? {};
@ -553,7 +623,6 @@ async function handlePrefsWizard(
}
}
} else {
// No authenticated models available — fall back to text input
for (const phase of modelPhases) {
const current = models[phase] ?? "";
const input = await ctx.ui.input(
@ -573,8 +642,9 @@ async function handlePrefsWizard(
if (Object.keys(models).length > 0) {
prefs.models = models;
}
}
// ─── Auto-supervisor timeouts ────────────────────────────────────────────
async function configureTimeouts(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const autoSup: Record<string, unknown> = (prefs.auto_supervisor as Record<string, unknown>) ?? {};
const timeoutFields = [
{ key: "soft_timeout_minutes", label: "Soft timeout (minutes)", defaultVal: "20" },
@ -603,8 +673,9 @@ async function handlePrefsWizard(
if (Object.keys(autoSup).length > 0) {
prefs.auto_supervisor = autoSup;
}
}
// ─── Git settings ───────────────────────────────────────────────────────
async function configureGit(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const git: Record<string, unknown> = (prefs.git as Record<string, unknown>) ?? {};
// main_branch
@ -705,7 +776,7 @@ async function handlePrefsWizard(
git.isolation = isolationChoice;
}
// ─── Git commit_docs ────────────────────────────────────────────────────
// commit_docs
const currentCommitDocs = git.commit_docs;
const commitDocsChoice = await ctx.ui.select(
`Track .gsd/ planning docs in git${currentCommitDocs !== undefined ? ` (current: ${currentCommitDocs})` : ""}:`,
@ -718,8 +789,10 @@ async function handlePrefsWizard(
if (Object.keys(git).length > 0) {
prefs.git = git;
}
}
// ─── Skill discovery mode ───────────────────────────────────────────────
async function configureSkills(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
// Skill discovery mode
const currentDiscovery = (prefs.skill_discovery as string) ?? "";
const discoveryChoice = await ctx.ui.select(
`Skill discovery mode${currentDiscovery ? ` (current: ${currentDiscovery})` : ""}:`,
@ -729,17 +802,18 @@ async function handlePrefsWizard(
prefs.skill_discovery = discoveryChoice;
}
// ─── Unique milestone IDs ──────────────────────────────────────────────
const currentUnique = prefs.unique_milestone_ids;
const uniqueChoice = await ctx.ui.select(
`Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`,
// UAT dispatch
const currentUat = prefs.uat_dispatch;
const uatChoice = await ctx.ui.select(
`UAT dispatch mode${currentUat !== undefined ? ` (current: ${currentUat})` : " (default: false)"}:`,
["true", "false", "(keep current)"],
);
if (uniqueChoice && uniqueChoice !== "(keep current)") {
prefs.unique_milestone_ids = uniqueChoice === "true";
if (uatChoice && uatChoice !== "(keep current)") {
prefs.uat_dispatch = uatChoice === "true";
}
}
// ─── Budget & cost control ────────────────────────────────────────────
async function configureBudget(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const currentCeiling = prefs.budget_ceiling;
const ceilingStr = currentCeiling !== undefined ? String(currentCeiling) : "";
const ceilingInput = await ctx.ui.input(
@ -785,8 +859,9 @@ async function handlePrefsWizard(
ctx.ui.notify(`Invalid context pause threshold "${val}" — must be 0-100. Keeping previous value.`, "warning");
}
}
}
// ─── Notifications ────────────────────────────────────────────────────
async function configureNotifications(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const notif: Record<string, boolean> = (prefs.notifications as Record<string, boolean>) ?? {};
const notifFields = [
{ key: "enabled", label: "Notifications enabled (master toggle)", defaultVal: true },
@ -811,15 +886,55 @@ async function handlePrefsWizard(
if (Object.keys(notif).length > 0) {
prefs.notifications = notif;
}
}
// ─── UAT dispatch ─────────────────────────────────────────────────────
const currentUat = prefs.uat_dispatch;
const uatChoice = await ctx.ui.select(
`UAT dispatch mode${currentUat !== undefined ? ` (current: ${currentUat})` : " (default: false)"}:`,
async function configureAdvanced(ctx: ExtensionCommandContext, prefs: Record<string, unknown>): Promise<void> {
const currentUnique = prefs.unique_milestone_ids;
const uniqueChoice = await ctx.ui.select(
`Unique milestone IDs${currentUnique !== undefined ? ` (current: ${currentUnique})` : ""}:`,
["true", "false", "(keep current)"],
);
if (uatChoice && uatChoice !== "(keep current)") {
prefs.uat_dispatch = uatChoice === "true";
if (uniqueChoice && uniqueChoice !== "(keep current)") {
prefs.unique_milestone_ids = uniqueChoice === "true";
}
}
// ─── Main wizard with category menu ─────────────────────────────────────────
async function handlePrefsWizard(
ctx: ExtensionCommandContext,
scope: "global" | "project",
): Promise<void> {
const path = scope === "project" ? getProjectGSDPreferencesPath() : getGlobalGSDPreferencesPath();
const existing = scope === "project" ? loadProjectGSDPreferences() : loadGlobalGSDPreferences();
const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
ctx.ui.notify(`GSD preferences (${scope}) — pick a category to configure.`, "info");
while (true) {
const summaries = buildCategorySummaries(prefs);
const options = [
`Models ${summaries.models}`,
`Timeouts ${summaries.timeouts}`,
`Git ${summaries.git}`,
`Skills ${summaries.skills}`,
`Budget ${summaries.budget}`,
`Notifications ${summaries.notifications}`,
`Advanced ${summaries.advanced}`,
`── Save & Exit ──`,
];
const raw = await ctx.ui.select("GSD Preferences", options);
const choice = typeof raw === "string" ? raw : "";
if (!choice || choice.includes("Save & Exit")) break;
if (choice.startsWith("Models")) await configureModels(ctx, prefs);
else if (choice.startsWith("Timeouts")) await configureTimeouts(ctx, prefs);
else if (choice.startsWith("Git")) await configureGit(ctx, prefs);
else if (choice.startsWith("Skills")) await configureSkills(ctx, prefs);
else if (choice.startsWith("Budget")) await configureBudget(ctx, prefs);
else if (choice.startsWith("Notifications")) await configureNotifications(ctx, prefs);
else if (choice.startsWith("Advanced")) await configureAdvanced(ctx, prefs);
}
// ─── Serialize to frontmatter ───────────────────────────────────────────