From 0c9cbf6b4c4bdfe1de1707a49c456e4d0656ab1e Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sun, 15 Mar 2026 06:54:11 +0100 Subject: [PATCH 1/5] fix(ux): differentiate skill diagnostics and improve prefs discoverability Split skill diagnostics into [Skill conflicts] (actual collisions) and [Skill issues] (validation warnings like missing description) so users aren't misled by the label. Add wizard hint to /gsd prefs output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/modes/interactive/interactive-mode.ts | 17 ++++++++++++++--- src/resources/extensions/gsd/commands.ts | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 4d098f59c..3f7a37848 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -998,9 +998,20 @@ export class InteractiveMode { if (showDiagnostics) { const skillDiagnostics = skillsResult.diagnostics; if (skillDiagnostics.length > 0) { - const warningLines = this.formatDiagnostics(skillDiagnostics, metadata); - this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); + const collisionDiags = skillDiagnostics.filter(d => d.type === "collision"); + const issueDiags = skillDiagnostics.filter(d => d.type !== "collision"); + + if (collisionDiags.length > 0) { + const collisionLines = this.formatDiagnostics(collisionDiags, metadata); + this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${collisionLines}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } + + if (issueDiags.length > 0) { + const issueLines = this.formatDiagnostics(issueDiags, metadata); + this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill issues]")}\n${issueLines}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } } const promptDiagnostics = promptsResult.diagnostics; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 1ebb86f09..781800211 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -540,5 +540,5 @@ async function ensurePreferencesFile( await ctx.waitForIdle(); await ctx.reload(); - ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.`, "info"); + ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.\nRun /gsd prefs wizard for interactive setup.`, "info"); } From 5377cfad509ce9aab462f5a202e9b3a4620b6794 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sun, 15 Mar 2026 07:13:23 +0100 Subject: [PATCH 2/5] fix(ux): launch prefs wizard directly from /gsd prefs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of just showing "Edit file" notification, /gsd prefs now ensures the preferences file exists and immediately launches the interactive wizard. This matches user expectation — typing "prefs" should let you edit preferences, not just show a file path. /gsd prefs status still available for file path info without wizard. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/commands.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 781800211..e3dc2f59c 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -215,20 +215,16 @@ export async function fireStatusViaCommand( async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { const trimmed = args.trim(); - if (trimmed === "" || trimmed === "global") { + if (trimmed === "" || trimmed === "global" || trimmed === "wizard" || trimmed === "setup" + || trimmed === "wizard global" || trimmed === "setup global") { await ensurePreferencesFile(getGlobalGSDPreferencesPath(), ctx, "global"); + await handlePrefsWizard(ctx, "global"); return; } - if (trimmed === "project") { + if (trimmed === "project" || trimmed === "wizard project" || trimmed === "setup project") { await ensurePreferencesFile(getProjectGSDPreferencesPath(), ctx, "project"); - return; - } - - if (trimmed === "wizard" || trimmed === "setup" || trimmed === "wizard global" || trimmed === "setup global" - || trimmed === "wizard project" || trimmed === "setup project") { - const scope = trimmed.includes("project") ? "project" : "global"; - await handlePrefsWizard(ctx, scope); + await handlePrefsWizard(ctx, "project"); return; } @@ -538,7 +534,4 @@ async function ensurePreferencesFile( ctx.ui.notify(`Using existing ${scope} GSD skill preferences at ${path}`, "info"); } - await ctx.waitForIdle(); - await ctx.reload(); - ctx.ui.notify(`Edit ${path} to update ${scope} GSD skill preferences.\nRun /gsd prefs wizard for interactive setup.`, "info"); } From be2492b48d010859948ca4faddf05ea1241c7ef5 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sun, 15 Mar 2026 07:19:09 +0100 Subject: [PATCH 3/5] fix(prefs): break parse/serialize cycle for empty arrays and objects The preferences parser treated [] and {} as strings instead of empty array/object. On next serialize, yamlSafeString quoted them as "[]" and "{}", permanently corrupting the preferences file. This caused the wizard to show empty fields (models, auto_supervisor, etc.). Fix: parseScalar now recognizes [] and {} (quoted or unquoted) as empty array/object. Serializer omits empty values entirely instead of writing key: [] or key: {}. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/commands.ts | 6 ++---- src/resources/extensions/gsd/preferences.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index e3dc2f59c..eae9b2214 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -448,8 +448,7 @@ function serializePreferencesToFrontmatter(prefs: Record): stri if (Array.isArray(value)) { if (value.length === 0) { - lines.push(`${prefix}${key}: []`); - return; + return; // Omit empty arrays — avoids parse/serialize cycle bug with "[]" strings } lines.push(`${prefix}${key}:`); for (const item of value) { @@ -480,8 +479,7 @@ function serializePreferencesToFrontmatter(prefs: Record): stri if (typeof value === "object") { const entries = Object.entries(value as Record); if (entries.length === 0) { - lines.push(`${prefix}${key}: {}`); - return; + return; // Omit empty objects — avoids parse/serialize cycle bug with "{}" strings } lines.push(`${prefix}${key}:`); for (const [k, v] of entries) { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 283f2dda4..0d271497b 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -482,16 +482,20 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { return root as GSDPreferences; } -function parseScalar(value: string): string | number | boolean { +function parseScalar(value: string): unknown { if (value === "true") return true; if (value === "false") return false; + // Recognize empty array/object literals (with or without surrounding quotes) + const unquoted = value.replace(/^['\"]|['\"]$/g, ""); + if (unquoted === "[]") return []; + if (unquoted === "{}") return {}; if (/^-?\d+$/.test(value)) { const n = Number(value); // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss if (Number.isSafeInteger(n)) return n; return value; } - return value.replace(/^['\"]|['\"]$/g, ""); + return unquoted; } /** From b97a47db42f062e866f7de84a13ecc73e2fb3f73 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sun, 15 Mar 2026 07:23:55 +0100 Subject: [PATCH 4/5] feat(tui): add placeholder support to Input component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Input component had no placeholder text support — when empty, it showed only "> " with a blinking cursor and no hint of expected input. The ExtensionInputComponent received a placeholder parameter but discarded it (_placeholder with underscore = intentionally unused). Fix: Input now has a public placeholder property. When value is empty, renders the placeholder in dim text. ExtensionInputComponent passes the placeholder through to Input. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modes/interactive/components/extension-input.ts | 5 ++++- packages/pi-tui/src/components/input.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts index 4c0e816bd..06d7ee933 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts @@ -33,7 +33,7 @@ export class ExtensionInputComponent extends Container implements Focusable { constructor( title: string, - _placeholder: string | undefined, + placeholder: string | undefined, onSubmit: (value: string) => void, onCancel: () => void, opts?: ExtensionInputOptions, @@ -61,6 +61,9 @@ export class ExtensionInputComponent extends Container implements Focusable { } this.input = new Input(); + if (placeholder) { + this.input.placeholder = placeholder; + } this.addChild(this.input); this.addChild(new Spacer(1)); this.addChild(new Text(`${keyHint("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`, 1, 0)); diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts index e5c3b4f7f..13714b138 100644 --- a/packages/pi-tui/src/components/input.ts +++ b/packages/pi-tui/src/components/input.ts @@ -20,6 +20,7 @@ export class Input implements Component, Focusable { private cursor: number = 0; // Cursor position in the value public onSubmit?: (value: string) => void; public onEscape?: () => void; + public placeholder: string = ""; /** Focusable interface - set by TUI when focus changes */ focused: boolean = false; @@ -440,6 +441,16 @@ export class Input implements Component, Focusable { return [prompt]; } + // Show placeholder when value is empty + if (this.value === "" && this.placeholder) { + const placeholderText = this.placeholder.slice(0, availableWidth - 1); + const marker = this.focused ? CURSOR_MARKER : ""; + const cursorChar = "\x1b[7m \x1b[27m"; // inverse space for cursor + const dimPlaceholder = `\x1b[2m${placeholderText}\x1b[22m`; // dim text + const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(placeholderText) - 1)); + return [prompt + marker + cursorChar + dimPlaceholder + padding]; + } + let visibleText = ""; let cursorDisplay = this.cursor; From 9aeacc803c70018132a82e23673fab7966c5ea68 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sun, 15 Mar 2026 07:33:06 +0100 Subject: [PATCH 5/5] feat(prefs): model selection via select list instead of free-text input The preferences wizard now shows available models from the model registry as a selectable list instead of requiring users to manually type model IDs. Falls back to text input when no authenticated models are available. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/commands.ts | 47 +++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index eae9b2214..c42ea113e 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -315,22 +315,41 @@ async function handlePrefsWizard( const modelPhases = ["research", "planning", "execution", "completion"] as const; const models: Record = (prefs.models as Record) ?? {}; - for (const phase of modelPhases) { - const current = models[phase] ?? ""; - const input = await ctx.ui.input( - `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, - current || "e.g. claude-sonnet-4-20250514", - ); - if (input !== null && input !== undefined) { - const val = input.trim(); - if (val) { - models[phase] = val; - } else if (current) { - // User cleared it — remove - delete models[phase]; + const availableModels = ctx.modelRegistry.getAvailable(); + if (availableModels.length > 0) { + const modelOptions = availableModels.map(m => `${m.id} · ${m.provider}`); + modelOptions.push("(keep current)", "(clear)"); + + for (const phase of modelPhases) { + const current = models[phase] ?? ""; + const title = `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`; + const choice = await ctx.ui.select(title, modelOptions); + + if (choice && choice !== "(keep current)") { + if (choice === "(clear)") { + delete models[phase]; + } else { + models[phase] = choice.split(" · ")[0]; + } + } + } + } 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( + `Model for ${phase} phase${current ? ` (current: ${current})` : ""}:`, + current || "e.g. claude-sonnet-4-20250514", + ); + if (input !== null && input !== undefined) { + const val = input.trim(); + if (val) { + models[phase] = val; + } else if (current) { + delete models[phase]; + } } } - // null/undefined = Escape/skip — keep existing value } if (Object.keys(models).length > 0) { prefs.models = models;