Merge branch 'main' into fix/gsd-extension-ctx-log

This commit is contained in:
TÂCHES 2026-03-15 09:59:08 -06:00 committed by GitHub
commit 996dc3d7dc
8 changed files with 213 additions and 41 deletions

View file

@ -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));

View file

@ -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;

View file

@ -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;

View file

@ -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)
}

View file

@ -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 <path>, 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 <path>, or /gsd remote [slack|discord|status|disconnect].`,
"warning",
);
},
@ -215,20 +221,16 @@ export async function fireStatusViaCommand(
async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
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<string, string> = (prefs.models as Record<string, string>) ?? {};
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<string, unknown>): 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<string, unknown>): stri
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
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<string, unknown>): 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<typeof AuthStorage> {
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<void> {
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");
}

View file

@ -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;

View file

@ -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;
}
/**

View file

@ -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',
)
},