Merge pull request #457 from deseltrus/fix/skill-diagnostics-ux

fix(ux): improve preferences wizard and skill diagnostics
This commit is contained in:
TÂCHES 2026-03-15 09:53:46 -06:00 committed by GitHub
commit c0ab967f35
5 changed files with 75 additions and 36 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

@ -215,20 +215,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 +315,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 +467,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 +498,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) {
@ -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");
}

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