From 172c4d3110b92b9d460e3a56edee59c4e7bd0a01 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 05:33:13 -0500 Subject: [PATCH] fix(gsd): align model switching and prefs surfaces --- .../extensions/gsd/commands-prefs-wizard.ts | 45 ++++++-- .../extensions/gsd/commands/catalog.ts | 3 +- .../extensions/gsd/commands/dispatcher.ts | 3 +- .../extensions/gsd/commands/handlers/core.ts | 109 +++++++++++++++++- .../tests/commands-workflow-custom.test.ts | 12 ++ .../gsd/tests/core-overlay-fallback.test.ts | 32 +++++ .../extensions/gsd/tests/preferences.test.ts | 17 +++ src/web/settings-service.ts | 15 +++ web/components/gsd/settings-panels.tsx | 29 ++++- web/lib/browser-slash-command-dispatch.ts | 3 +- web/lib/settings-types.ts | 1 + 11 files changed, 253 insertions(+), 16 deletions(-) diff --git a/src/resources/extensions/gsd/commands-prefs-wizard.ts b/src/resources/extensions/gsd/commands-prefs-wizard.ts index 7a475fe54..f94a78010 100644 --- a/src/resources/extensions/gsd/commands-prefs-wizard.ts +++ b/src/resources/extensions/gsd/commands-prefs-wizard.ts @@ -165,10 +165,10 @@ export function buildCategorySummaries(prefs: Record): Record | undefined; + const models = prefs.models as Record | undefined; let modelsSummary = "(not configured)"; if (models && Object.keys(models).length > 0) { - const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${model}`); + const parts = Object.entries(models).map(([phase, model]) => `${phase}: ${formatConfiguredModel(model)}`); modelsSummary = parts.join(", "); } @@ -255,9 +255,38 @@ export function buildCategorySummaries(prefs: Record): Record): Promise { - const modelPhases = ["research", "planning", "execution", "completion"] as const; - const models: Record = (prefs.models as Record) ?? {}; + const modelPhases = [ + "research", + "planning", + "discuss", + "execution", + "execution_simple", + "completion", + "validation", + "subagent", + ] as const; + const models: Record = (prefs.models as Record) ?? {}; const availableModels = ctx.modelRegistry.getAvailable(); if (availableModels.length > 0) { @@ -292,7 +321,7 @@ async function configureModels(ctx: ExtensionCommandContext, prefs: Record 0) { prefs.models = models; + } else { + delete prefs.models; } } diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index a232a2001..02e745969 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -41,6 +41,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "skip", desc: "Prevent a unit from auto-mode dispatch" }, { cmd: "export", desc: "Export milestone/slice results" }, { cmd: "cleanup", desc: "Remove merged branches or snapshots" }, + { cmd: "model", desc: "Switch the active session model or open a picker" }, { cmd: "mode", desc: "Switch workflow mode (solo/team)" }, { cmd: "prefs", desc: "Manage preferences (model selection, timeouts, etc.)" }, { cmd: "config", desc: "Set API keys for external tools" }, diff --git a/src/resources/extensions/gsd/commands/dispatcher.ts b/src/resources/extensions/gsd/commands/dispatcher.ts index 9f28cbbaa..a3d11344b 100644 --- a/src/resources/extensions/gsd/commands/dispatcher.ts +++ b/src/resources/extensions/gsd/commands/dispatcher.ts @@ -14,7 +14,7 @@ export async function handleGSDCommand( const trimmed = (typeof args === "string" ? args : "").trim(); const handlers = [ - () => handleCoreCommand(trimmed, ctx), + () => handleCoreCommand(trimmed, ctx, pi), () => handleAutoCommand(trimmed, ctx, pi), () => handleParallelCommand(trimmed, ctx, pi), () => handleWorkflowCommand(trimmed, ctx, pi), @@ -29,4 +29,3 @@ export async function handleGSDCommand( ctx.ui.notify(`Unknown: /gsd ${trimmed}. Run /gsd help for available commands.`, "warning"); } - diff --git a/src/resources/extensions/gsd/commands/handlers/core.ts b/src/resources/extensions/gsd/commands/handlers/core.ts index 5461aa40d..d37a3d4c0 100644 --- a/src/resources/extensions/gsd/commands/handlers/core.ts +++ b/src/resources/extensions/gsd/commands/handlers/core.ts @@ -1,4 +1,5 @@ -import type { ExtensionCommandContext, ExtensionContext } from "@gsd/pi-coding-agent"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@gsd/pi-coding-agent"; +import type { Model } from "@gsd/pi-ai"; import type { GSDState } from "../../types.js"; import { computeProgressScore, formatProgressLine } from "../../progress-score.js"; @@ -48,6 +49,7 @@ export function showHelp(ctx: ExtensionCommandContext): void { "SETUP & CONFIGURATION", " /gsd init Project init wizard — detect, configure, bootstrap .gsd/", " /gsd setup Global setup status [llm|search|remote|keys|prefs]", + " /gsd model Switch active session model [provider/model|model-id]", " /gsd mode Set workflow mode (solo/team) [global|project]", " /gsd prefs Manage preferences [global|project|status|wizard|setup|import-claude]", " /gsd cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]", @@ -179,7 +181,106 @@ export async function handleSetup(args: string, ctx: ExtensionCommandContext): P ); } -export async function handleCoreCommand(trimmed: string, ctx: ExtensionCommandContext): Promise { +function sortModelsForSelection(models: Model[], currentModel: Model | undefined): Model[] { + return [...models].sort((a, b) => { + const aCurrent = currentModel && a.provider === currentModel.provider && a.id === currentModel.id; + const bCurrent = currentModel && b.provider === currentModel.provider && b.id === currentModel.id; + if (aCurrent && !bCurrent) return -1; + if (!aCurrent && bCurrent) return 1; + const providerCmp = a.provider.localeCompare(b.provider); + if (providerCmp !== 0) return providerCmp; + return a.id.localeCompare(b.id); + }); +} + +async function resolveRequestedModel( + query: string, + ctx: ExtensionCommandContext, +): Promise | undefined> { + const { resolveModelId } = await import("../../auto-model-selection.js"); + const models = ctx.modelRegistry.getAvailable(); + const exact = resolveModelId(query, models, ctx.model?.provider); + if (exact) return exact; + + const lowerQuery = query.toLowerCase(); + const partialMatches = models.filter((model) => + model.id.toLowerCase().includes(lowerQuery) + || `${model.provider}/${model.id}`.toLowerCase().includes(lowerQuery), + ); + + if (partialMatches.length === 1) return partialMatches[0]; + if (partialMatches.length === 0 || !ctx.hasUI) return undefined; + + const sorted = sortModelsForSelection(partialMatches, ctx.model); + const optionToModel = new Map>(); + const options = sorted.map((model) => { + const label = `${model.provider}/${model.id}`; + optionToModel.set(label, model); + return label; + }); + options.push("(cancel)"); + + const choice = await ctx.ui.select(`Multiple models match "${query}" — choose one:`, options); + if (!choice || typeof choice !== "string" || choice === "(cancel)") return undefined; + return optionToModel.get(choice); +} + +async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi: ExtensionAPI | undefined): Promise { + const availableModels = ctx.modelRegistry.getAvailable(); + if (availableModels.length === 0) { + ctx.ui.notify("No available models found. Check provider auth and model discovery.", "warning"); + return; + } + if (!pi) { + ctx.ui.notify("Model switching is unavailable in this context.", "warning"); + return; + } + + const trimmed = trimmedArgs.trim(); + let targetModel: Model | undefined; + + if (!trimmed) { + if (!ctx.hasUI) { + const current = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "(none)"; + ctx.ui.notify(`Current model: ${current}\nUsage: /gsd model `, "info"); + return; + } + + const optionToModel = new Map>(); + const options = sortModelsForSelection(availableModels, ctx.model).map((model) => { + const isCurrent = ctx.model && model.provider === ctx.model.provider && model.id === ctx.model.id; + const label = `${isCurrent ? "* " : ""}${model.provider}/${model.id}`; + optionToModel.set(label, model); + return label; + }); + options.push("(cancel)"); + + const choice = await ctx.ui.select("Select session model:", options); + if (!choice || typeof choice !== "string" || choice === "(cancel)") return; + targetModel = optionToModel.get(choice); + } else { + targetModel = await resolveRequestedModel(trimmed, ctx); + } + + if (!targetModel) { + ctx.ui.notify(`Model "${trimmed}" not found. Use /gsd model with an exact provider/model or a unique model ID.`, "warning"); + return; + } + + const ok = await pi.setModel(targetModel); + if (!ok) { + ctx.ui.notify(`No API key for ${targetModel.provider}/${targetModel.id}`, "warning"); + return; + } + + ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info"); +} + +export async function handleCoreCommand( + trimmed: string, + ctx: ExtensionCommandContext, + pi?: ExtensionAPI, +): Promise { if (trimmed === "help" || trimmed === "h" || trimmed === "?") { showHelp(ctx); return true; @@ -203,6 +304,10 @@ export async function handleCoreCommand(trimmed: string, ctx: ExtensionCommandCo ctx.ui.notify(`Widget: ${getWidgetMode()}`, "info"); return true; } + if (trimmed === "model" || trimmed.startsWith("model ")) { + await handleModel(trimmed.replace(/^model\s*/, "").trim(), ctx, pi); + return true; + } if (trimmed === "mode" || trimmed.startsWith("mode ")) { const modeArgs = trimmed.replace(/^mode\s*/, "").trim(); const scope = modeArgs === "project" ? "project" : "global"; diff --git a/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts b/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts index 16642a7eb..537bcab4d 100644 --- a/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts +++ b/src/resources/extensions/gsd/tests/commands-workflow-custom.test.ts @@ -100,6 +100,18 @@ steps: [] // ─── Catalog Registration ──────────────────────────────────────────────── describe("workflow catalog registration", () => { + it("model appears in TOP_LEVEL_SUBCOMMANDS", () => { + const entry = TOP_LEVEL_SUBCOMMANDS.find((c) => c.cmd === "model"); + assert.ok(entry, "model should be in TOP_LEVEL_SUBCOMMANDS"); + assert.match(entry!.desc, /session model/i); + }); + + it("getGsdArgumentCompletions('m') includes model", () => { + const completions = getGsdArgumentCompletions("m"); + const labels = completions.map((c: any) => c.label); + assert.ok(labels.includes("model"), "should include model completion"); + }); + it("workflow appears in TOP_LEVEL_SUBCOMMANDS", () => { const entry = TOP_LEVEL_SUBCOMMANDS.find((c) => c.cmd === "workflow"); assert.ok(entry, "workflow should be in TOP_LEVEL_SUBCOMMANDS"); diff --git a/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts index 7ea26ce66..a6c2dc6d9 100644 --- a/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts +++ b/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts @@ -42,3 +42,35 @@ test("show-config only falls back when ctx.ui.custom() is unavailable", async () assert.equal(fallbackCtx.notices.length, 1, "unavailable overlay triggers text fallback"); assert.match(fallbackCtx.notices[0]!.message, /GSD Configuration/); }); + +test("model command resolves and persists exact provider-qualified selection", async () => { + const selectedModel = { provider: "openai", id: "gpt-5.4" }; + let applied: typeof selectedModel | null = null; + const ctx = { + hasUI: true, + model: { provider: "anthropic", id: "claude-sonnet-4-6" }, + modelRegistry: { + getAvailable: () => [ + { provider: "anthropic", id: "claude-sonnet-4-6" }, + selectedModel, + ], + }, + ui: { + notify: (message: string, type?: string) => { + notices.push({ message, type }); + }, + }, + } as any; + const notices: Array<{ message: string; type?: string }> = []; + const pi = { + setModel: async (model: typeof selectedModel) => { + applied = model; + return true; + }, + } as any; + + const handled = await handleCoreCommand("model openai/gpt-5.4", ctx, pi); + assert.equal(handled, true); + assert.deepEqual(applied, selectedModel); + assert.match(notices[0]!.message, /openai\/gpt-5\.4/); +}); diff --git a/src/resources/extensions/gsd/tests/preferences.test.ts b/src/resources/extensions/gsd/tests/preferences.test.ts index 79e36893c..7e5f4177e 100644 --- a/src/resources/extensions/gsd/tests/preferences.test.ts +++ b/src/resources/extensions/gsd/tests/preferences.test.ts @@ -17,6 +17,7 @@ import { parsePreferencesMarkdown, _resetParseWarningFlag, } from "../preferences.ts"; +import { formatConfiguredModel, toPersistedModelId } from "../commands-prefs-wizard.ts"; import { _resetLogs, peekLogs } from "../workflow-logger.ts"; import type { GSDPreferences, GSDModelConfigV2, GSDPhaseModelConfig } from "../preferences.ts"; @@ -347,6 +348,22 @@ test("handles model config with explicit provider field", () => { assert.equal(execution.provider, "bedrock"); }); +test("formatConfiguredModel renders provider-qualified object config", () => { + assert.equal( + formatConfiguredModel({ model: "claude-opus-4-6", provider: "bedrock" }), + "bedrock/claude-opus-4-6", + ); +}); + +test("toPersistedModelId prefixes provider chosen in prefs wizard", () => { + assert.equal(toPersistedModelId("openai", "gpt-5.4"), "openai/gpt-5.4"); + assert.equal( + toPersistedModelId("openai", "openai/gpt-5.4"), + "openai/gpt-5.4", + "already-qualified IDs should be preserved", + ); +}); + test("handles empty models config", () => { const prefs = parsePreferencesMarkdown("---\nversion: 1\n---\n"); assert.notEqual(prefs, null); diff --git a/src/web/settings-service.ts b/src/web/settings-service.ts index 7a2a8df24..f9c850420 100644 --- a/src/web/settings-service.ts +++ b/src/web/settings-service.ts @@ -73,8 +73,23 @@ export async function collectSettingsData(projectCwdOverride?: string): Promise< 'let preferences = null;', 'if (loaded) {', ' const p = loaded.preferences;', + ' const models = {};', + ' if (p.models && typeof p.models === "object") {', + ' for (const [phase, value] of Object.entries(p.models)) {', + ' if (typeof value === "string") {', + ' models[phase] = value;', + ' continue;', + ' }', + ' if (value && typeof value === "object" && typeof value.model === "string") {', + ' models[phase] = typeof value.provider === "string" && value.provider && !value.model.includes("/")', + ' ? `${value.provider}/${value.model}`', + ' : value.model;', + ' }', + ' }', + ' }', ' preferences = {', ' mode: p.mode,', + ' models: Object.keys(models).length > 0 ? models : undefined,', ' budgetCeiling: p.budget_ceiling,', ' budgetEnforcement: p.budget_enforcement,', ' tokenProfile: p.token_profile,', diff --git a/web/components/gsd/settings-panels.tsx b/web/components/gsd/settings-panels.tsx index 9a3385199..f945295e1 100644 --- a/web/components/gsd/settings-panels.tsx +++ b/web/components/gsd/settings-panels.tsx @@ -139,6 +139,24 @@ function SkillBadgeList({ label, skills }: { label: string; skills: string[] | u ) } +function ModelBadgeList({ models }: { models: Record | undefined }) { + if (!models || Object.keys(models).length === 0) return null + return ( +
+ Phase Models +
+ {Object.entries(models) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([phase, model]) => ( + + {phase}: {model} + + ))} +
+
+ ) +} + function KvRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -206,12 +224,17 @@ export function PrefsPanel() { {/* Skills */}
+ - {!prefs.alwaysUseSkills?.length && !prefs.preferSkills?.length && !prefs.avoidSkills?.length && ( - No skill preferences configured - )} + {!prefs.models || Object.keys(prefs.models).length === 0 + ? !prefs.alwaysUseSkills?.length && !prefs.preferSkills?.length && !prefs.avoidSkills?.length && ( + No model or skill preferences configured + ) + : !prefs.alwaysUseSkills?.length && !prefs.preferSkills?.length && !prefs.avoidSkills?.length && ( + No skill preferences configured + )}
{/* Toggles */} diff --git a/web/lib/browser-slash-command-dispatch.ts b/web/lib/browser-slash-command-dispatch.ts index d8a3f2e4f..0af50a412 100644 --- a/web/lib/browser-slash-command-dispatch.ts +++ b/web/lib/browser-slash-command-dispatch.ts @@ -126,6 +126,7 @@ const GSD_SURFACE_SUBCOMMANDS = new Map([ ["history", "gsd-history"], ["undo", "gsd-undo"], ["inspect", "gsd-inspect"], + ["model", "model"], ["prefs", "gsd-prefs"], ["config", "gsd-config"], ["hooks", "gsd-hooks"], @@ -153,7 +154,7 @@ export const GSD_HELP_TEXT = `Available /gsd subcommands: Workflow: next · auto · stop · pause · skip · queue · quick · capture · triage Diagnostics: status · visualize · forensics · doctor · skill-health · inspect Context: knowledge · history · undo · discuss -Settings: prefs · config · hooks · mode · steer +Settings: model · prefs · config · hooks · mode · steer Advanced: export · cleanup · run-hook · migrate · remote Type /gsd to run. Use /gsd help for this message.` diff --git a/web/lib/settings-types.ts b/web/lib/settings-types.ts index 5a28175c2..7d06c31d0 100644 --- a/web/lib/settings-types.ts +++ b/web/lib/settings-types.ts @@ -87,6 +87,7 @@ export interface SettingsProjectTotals { export interface SettingsPreferencesData { mode?: SettingsWorkflowMode + models?: Record budgetCeiling?: number budgetEnforcement?: SettingsBudgetEnforcement tokenProfile?: SettingsTokenProfile