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/cli.ts b/src/cli.ts index fa70b501b..0836cd9c5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -113,6 +113,7 @@ const isPrintMode = cliFlags.print || cliFlags.mode !== undefined // `gsd config` — replay the setup wizard and exit if (cliFlags.messages[0] === 'config') { const authStorage = AuthStorage.create(authFilePath) + loadStoredEnvKeys(authStorage) await runOnboarding(authStorage) process.exit(0) } diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 1ebb86f09..7aefa0270 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -5,7 +5,8 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; -import { existsSync, readFileSync } from "node:fs"; +import { AuthStorage } from "@gsd/pi-coding-agent"; +import { existsSync, readFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { deriveState } from "./state.js"; @@ -53,10 +54,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote", + description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|config|hooks|doctor|migrate|remote", getArgumentCompletions: (prefix: string) => { - const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"]; + const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "config", "hooks", "doctor", "migrate", "remote"]; const parts = prefix.trim().split(/\s+/); if (parts.length <= 1) { @@ -151,6 +152,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "config") { + await handleConfig(ctx); + return; + } + if (trimmed === "hooks") { const { formatHookStatus } = await import("./post-unit-hooks.js"); ctx.ui.notify(formatHookStatus(), "info"); @@ -174,7 +180,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs, /gsd config, /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, @@ -215,20 +221,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 +321,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 +473,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 +504,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) { @@ -521,6 +540,74 @@ function serializePreferencesToFrontmatter(prefs: Record): stri return lines.join("\n") + "\n"; } +// ─── Tool Config Wizard ─────────────────────────────────────────────────────── + +const TOOL_KEYS = [ + { id: "tavily", env: "TAVILY_API_KEY", label: "Tavily Search", hint: "tavily.com/app/api-keys" }, + { id: "brave", env: "BRAVE_API_KEY", label: "Brave Search", hint: "brave.com/search/api" }, + { id: "context7", env: "CONTEXT7_API_KEY", label: "Context7 Docs", hint: "context7.com/dashboard" }, + { id: "jina", env: "JINA_API_KEY", label: "Jina Page Extract", hint: "jina.ai/api" }, + { id: "groq", env: "GROQ_API_KEY", label: "Groq Voice", hint: "console.groq.com" }, +] as const; + +function getConfigAuthStorage(): InstanceType { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + mkdirSync(dirname(authPath), { recursive: true }); + return AuthStorage.create(authPath); +} + +async function handleConfig(ctx: ExtensionCommandContext): Promise { + const auth = getConfigAuthStorage(); + + // Show current status + const statusLines = ["GSD Tool Configuration\n"]; + for (const tool of TOOL_KEYS) { + const hasKey = !!process.env[tool.env] || !!(auth.get(tool.id) as { key?: string })?.key; + statusLines.push(` ${hasKey ? "✓" : "✗"} ${tool.label}${hasKey ? "" : ` — get key at ${tool.hint}`}`); + } + ctx.ui.notify(statusLines.join("\n"), "info"); + + // Ask which tools to configure + const options = TOOL_KEYS.map(t => { + const hasKey = !!process.env[t.env] || !!(auth.get(t.id) as { key?: string })?.key; + return `${t.label} ${hasKey ? "(configured ✓)" : "(not set)"}`; + }); + options.push("(done)"); + + let changed = false; + while (true) { + const choice = await ctx.ui.select("Configure which tool? Press Escape when done.", options); + if (!choice || choice === "(done)") break; + + const toolIdx = TOOL_KEYS.findIndex(t => choice.startsWith(t.label)); + if (toolIdx === -1) break; + + const tool = TOOL_KEYS[toolIdx]; + const input = await ctx.ui.input( + `API key for ${tool.label} (${tool.hint}):`, + "paste your key here", + ); + + if (input !== null && input !== undefined) { + const key = input.trim(); + if (key) { + auth.set(tool.id, { type: "api_key", key }); + process.env[tool.env] = key; + ctx.ui.notify(`${tool.label} key saved and activated.`, "info"); + // Update option label + options[toolIdx] = `${tool.label} (configured ✓)`; + changed = true; + } + } + } + + if (changed) { + await ctx.waitForIdle(); + await ctx.reload(); + ctx.ui.notify("Configuration saved. Extensions reloaded with new keys.", "info"); + } +} + async function ensurePreferencesFile( path: string, ctx: ExtensionCommandContext, @@ -538,7 +625,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/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 5ad3cc766..d85c31862 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -467,6 +467,62 @@ export async function showDiscuss( const mid = state.activeMilestone.id; const milestoneTitle = state.activeMilestone.title; + // Special case: milestone is in needs-discussion phase (has CONTEXT-DRAFT.md but no roadmap yet). + // Route to the draft discussion flow instead of erroring — the discussion IS how the roadmap gets created. + if (state.phase === "needs-discussion") { + const draftFile = resolveMilestoneFile(basePath, mid, "CONTEXT-DRAFT"); + const draftContent = draftFile ? await loadFile(draftFile) : null; + + const choice = await showNextAction(ctx as any, { + title: `GSD — ${mid}: ${milestoneTitle}`, + summary: ["This milestone has a draft context from a prior discussion.", "It needs a dedicated discussion before auto-planning can begin."], + actions: [ + { + id: "discuss_draft", + label: "Discuss from draft", + description: "Continue where the prior discussion left off — seed material is loaded automatically.", + recommended: true, + }, + { + id: "discuss_fresh", + label: "Start fresh discussion", + description: "Discard the draft and start a new discussion from scratch.", + }, + { + id: "skip_milestone", + label: "Skip — create new milestone", + description: "Leave this milestone as-is and start something new.", + }, + ], + notYetMessage: "Run /gsd discuss when ready to discuss this milestone.", + }); + + if (choice === "discuss_draft") { + const discussMilestoneTemplates = inlineTemplate("context", "Context"); + const basePrompt = loadPrompt("guided-discuss-milestone", { + milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, + }); + const seed = draftContent + ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` + : basePrompt; + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; + dispatchWorkflow(pi, seed, "gsd-discuss"); + } else if (choice === "discuss_fresh") { + const discussMilestoneTemplates = inlineTemplate("context", "Context"); + pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; + dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { + milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, + }), "gsd-discuss"); + } else if (choice === "skip_milestone") { + const milestoneIds = findMilestoneIds(basePath); + const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; + const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds); + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false }; + dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath)); + } + return; + } + // Guard: no roadmap yet const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP"); const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; 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; } /** diff --git a/src/resources/extensions/search-the-web/command-search-provider.ts b/src/resources/extensions/search-the-web/command-search-provider.ts index e715341ce..ee6520e7d 100644 --- a/src/resources/extensions/search-the-web/command-search-provider.ts +++ b/src/resources/extensions/search-the-web/command-search-provider.ts @@ -90,8 +90,10 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void { setSearchProviderPreference(chosen) const effective = resolveSearchProvider() + const isAnthropic = ctx.model?.provider === 'anthropic' + const nativeNote = isAnthropic ? '\nNote: Native Anthropic web search is also active (automatic, no API key needed).' : '' ctx.ui.notify( - `Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}`, + `Search provider set to ${chosen}. Effective provider: ${effective ?? 'none (no API keys)'}${nativeNote}`, 'info', ) },