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-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/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; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 1ebb86f09..c42ea113e 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; } @@ -319,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; @@ -452,8 +467,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) { @@ -484,8 +498,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) { @@ -538,7 +551,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.`, "info"); } diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index f2f7bef66..2f06c7154 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; } /**