Merge branch 'main' into fix/gsd-extension-ctx-log
This commit is contained in:
commit
996dc3d7dc
8 changed files with 213 additions and 41 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue