feat(gsd): add /gsd show-config command

Adds a read-only command to display the current effective GSD
configuration — token profile, model assignments per phase, dynamic
routing tiers, git settings, budget, supervisor, workflow toggles,
parallel config, hooks, and preference file sources.

Renders as a themed TUI overlay (scrollable, esc/q to close) with
plain-text fallback for headless/RPC mode.
This commit is contained in:
Jeremy 2026-03-29 15:12:04 -05:00
parent 0a2c9b64c6
commit ea088f498d
3 changed files with 407 additions and 0 deletions

View file

@ -51,6 +51,7 @@ export function showHelp(ctx: ExtensionCommandContext): void {
" /gsd cmux Manage cmux integration [status|on|off|notifications|sidebar|splits|browser]",
" /gsd config Set API keys for external tools",
" /gsd keys API key manager [list|add|remove|test|rotate|doctor]",
" /gsd show-config Show effective configuration (models, routing, toggles)",
" /gsd hooks Show post-unit hook configuration",
" /gsd extensions Manage extensions [list|enable|disable|info]",
" /gsd fast Toggle OpenAI service tier [on|off|flex|status]",
@ -213,6 +214,25 @@ export async function handleCoreCommand(trimmed: string, ctx: ExtensionCommandCo
await handleCmux(trimmed.replace(/^cmux\s*/, "").trim(), ctx);
return true;
}
if (trimmed === "show-config") {
const { GSDConfigOverlay, formatConfigText } = await import("../../config-overlay.js");
const result = await ctx.ui.custom<void>(
(tui, theme, _kb, done) => new GSDConfigOverlay(tui, theme, () => done()),
{
overlay: true,
overlayOptions: {
width: "65%",
minWidth: 55,
maxHeight: "85%",
anchor: "center",
},
},
);
if (result === undefined) {
ctx.ui.notify(formatConfigText(), "info");
}
return true;
}
if (trimmed === "setup" || trimmed.startsWith("setup ")) {
await handleSetup(trimmed.replace(/^setup\s*/, "").trim(), ctx);
return true;

View file

@ -0,0 +1,331 @@
/**
* GSD Configuration Overlay
*
* Read-only TUI overlay showing the effective GSD configuration:
* token profile, model assignments, dynamic routing, git settings,
* budget, workflow toggles, and preference file sources.
* Opened via `/gsd show-config` or `/gsd config`.
*/
import type { Theme } from "@gsd/pi-coding-agent";
import { matchesKey, Key, truncateToWidth } from "@gsd/pi-tui";
import {
loadEffectiveGSDPreferences,
loadGlobalGSDPreferences,
loadProjectGSDPreferences,
getGlobalGSDPreferencesPath,
getProjectGSDPreferencesPath,
resolveDynamicRoutingConfig,
resolveEffectiveProfile,
resolveModelWithFallbacksForUnit,
resolveAutoSupervisorConfig,
} from "./preferences.js";
// ─── Data Collection ──────────────────────────────────────────────────────
interface ConfigSection {
title: string;
rows: Array<{ label: string; value: string; accent?: boolean }>;
}
function collectConfigSections(): ConfigSection[] {
const sections: ConfigSection[] = [];
const globalPrefs = loadGlobalGSDPreferences();
const projectPrefs = loadProjectGSDPreferences();
const effective = loadEffectiveGSDPreferences();
const prefs = effective?.preferences;
// ─── Sources ─────────────────────────────────────────────────────────
sections.push({
title: "Sources",
rows: [
{ label: "Global", value: globalPrefs ? globalPrefs.path : `(none) ${getGlobalGSDPreferencesPath()}` },
{ label: "Project", value: projectPrefs ? projectPrefs.path : `(none) ${getProjectGSDPreferencesPath()}` },
],
});
// ─── Profile ─────────────────────────────────────────────────────────
const profile = resolveEffectiveProfile();
const profileRows: ConfigSection["rows"] = [
{ label: "Token profile", value: `${profile}${!prefs?.token_profile ? " (default)" : ""}`, accent: true },
];
if (prefs?.mode) profileRows.push({ label: "Workflow mode", value: prefs.mode });
sections.push({ title: "Profile", rows: profileRows });
// ─── Models ──────────────────────────────────────────────────────────
const unitTypes: Array<[string, string]> = [
["research", "research-milestone"],
["planning", "plan-milestone"],
["discuss", "discuss-milestone"],
["execution", "execute-task"],
["completion", "complete-slice"],
["validation", "run-uat"],
];
const modelRows: ConfigSection["rows"] = [];
for (const [label, unitType] of unitTypes) {
const resolved = resolveModelWithFallbacksForUnit(unitType);
if (resolved) {
let val = resolved.primary;
if (resolved.fallbacks.length > 0) {
val += ` \u2192 ${resolved.fallbacks.join(" \u2192 ")}`;
}
modelRows.push({ label, value: val });
} else {
modelRows.push({ label, value: "(inherit)" });
}
}
// subagent is a direct config key
const models = prefs?.models as Record<string, unknown> | undefined;
const subVal = models?.subagent;
if (subVal) {
const model = typeof subVal === "string" ? subVal : (subVal as { model?: string })?.model ?? "?";
modelRows.push({ label: "subagent", value: model });
} else {
modelRows.push({ label: "subagent", value: "(inherit)" });
}
sections.push({ title: "Models", rows: modelRows });
// ─── Dynamic Routing ─────────────────────────────────────────────────
const routing = resolveDynamicRoutingConfig();
const routingRows: ConfigSection["rows"] = [
{ label: "Enabled", value: routing.enabled ? "yes" : "no", accent: routing.enabled },
];
if (routing.enabled) {
routingRows.push({ label: "Escalate on fail", value: routing.escalate_on_failure !== false ? "yes" : "no" });
routingRows.push({ label: "Budget pressure", value: routing.budget_pressure !== false ? "yes" : "no" });
routingRows.push({ label: "Cross-provider", value: routing.cross_provider !== false ? "yes" : "no" });
if (routing.tier_models) {
const tm = routing.tier_models;
if (tm.light) routingRows.push({ label: "[L] light", value: tm.light });
if (tm.standard) routingRows.push({ label: "[S] standard", value: tm.standard });
if (tm.heavy) routingRows.push({ label: "[H] heavy", value: tm.heavy });
}
}
sections.push({ title: "Dynamic Routing", rows: routingRows });
// ─── Git ─────────────────────────────────────────────────────────────
if (prefs?.git) {
const g = prefs.git;
const gitRows: ConfigSection["rows"] = [];
if (g.isolation !== undefined) gitRows.push({ label: "Isolation", value: String(g.isolation) });
if (g.auto_push !== undefined) gitRows.push({ label: "Auto push", value: String(g.auto_push) });
if (g.push_branches !== undefined) gitRows.push({ label: "Push branches", value: String(g.push_branches) });
if (g.merge_strategy) gitRows.push({ label: "Merge strategy", value: g.merge_strategy });
if (g.main_branch) gitRows.push({ label: "Main branch", value: g.main_branch });
if (g.remote) gitRows.push({ label: "Remote", value: g.remote });
if (gitRows.length > 0) sections.push({ title: "Git", rows: gitRows });
}
// ─── Budget ──────────────────────────────────────────────────────────
if (prefs?.budget_ceiling !== undefined || prefs?.budget_enforcement) {
const budgetRows: ConfigSection["rows"] = [];
if (prefs.budget_ceiling !== undefined) budgetRows.push({ label: "Ceiling", value: `$${prefs.budget_ceiling}` });
if (prefs.budget_enforcement) budgetRows.push({ label: "Enforcement", value: String(prefs.budget_enforcement) });
sections.push({ title: "Budget", rows: budgetRows });
}
// ─── Auto Supervisor ─────────────────────────────────────────────────
if (prefs?.auto_supervisor) {
const sup = resolveAutoSupervisorConfig();
const supRows: ConfigSection["rows"] = [];
if (sup.model) supRows.push({ label: "Model", value: sup.model });
supRows.push({ label: "Soft timeout", value: `${sup.soft_timeout_minutes}m` });
supRows.push({ label: "Idle timeout", value: `${sup.idle_timeout_minutes}m` });
supRows.push({ label: "Hard timeout", value: `${sup.hard_timeout_minutes}m` });
sections.push({ title: "Auto Supervisor", rows: supRows });
}
// ─── Toggles ─────────────────────────────────────────────────────────
const toggleRows: ConfigSection["rows"] = [];
if (prefs?.phases) {
const p = prefs.phases;
if (p.skip_research) toggleRows.push({ label: "skip_research", value: "on" });
if (p.skip_reassess) toggleRows.push({ label: "skip_reassess", value: "on" });
if (p.skip_slice_research) toggleRows.push({ label: "skip_slice_research", value: "on" });
if (p.skip_milestone_validation) toggleRows.push({ label: "skip_milestone_validation", value: "on" });
if (p.require_slice_discussion) toggleRows.push({ label: "require_slice_discussion", value: "on" });
}
if (prefs?.uat_dispatch) toggleRows.push({ label: "uat_dispatch", value: "on" });
if (prefs?.auto_visualize) toggleRows.push({ label: "auto_visualize", value: "on" });
if (prefs?.auto_report === false) toggleRows.push({ label: "auto_report", value: "off" });
if (prefs?.show_token_cost) toggleRows.push({ label: "show_token_cost", value: "on" });
if (prefs?.forensics_dedup) toggleRows.push({ label: "forensics_dedup", value: "on" });
if (prefs?.unique_milestone_ids) toggleRows.push({ label: "unique_milestone_ids", value: "on" });
if (prefs?.service_tier) toggleRows.push({ label: "service_tier", value: prefs.service_tier });
if (prefs?.search_provider && prefs.search_provider !== "auto") toggleRows.push({ label: "search_provider", value: prefs.search_provider });
if (prefs?.context_selection) toggleRows.push({ label: "context_selection", value: prefs.context_selection });
if (prefs?.widget_mode && prefs.widget_mode !== "full") toggleRows.push({ label: "widget_mode", value: prefs.widget_mode });
if (prefs?.experimental?.rtk) toggleRows.push({ label: "experimental.rtk", value: "on" });
if (toggleRows.length > 0) sections.push({ title: "Toggles", rows: toggleRows });
// ─── Parallel ────────────────────────────────────────────────────────
if (prefs?.parallel) {
const pc = prefs.parallel;
const parallelRows: ConfigSection["rows"] = [];
if (pc.max_workers !== undefined) parallelRows.push({ label: "Max workers", value: String(pc.max_workers) });
if (pc.merge_strategy) parallelRows.push({ label: "Merge strategy", value: pc.merge_strategy });
if (pc.auto_merge) parallelRows.push({ label: "Auto merge", value: pc.auto_merge });
if (parallelRows.length > 0) sections.push({ title: "Parallel", rows: parallelRows });
}
// ─── Hooks ───────────────────────────────────────────────────────────
const postHooks = prefs?.post_unit_hooks?.filter(h => h.enabled !== false) ?? [];
const preHooks = prefs?.pre_dispatch_hooks?.filter(h => h.enabled !== false) ?? [];
if (postHooks.length > 0 || preHooks.length > 0) {
const hookRows: ConfigSection["rows"] = [];
if (preHooks.length > 0) hookRows.push({ label: "Pre-dispatch", value: `${preHooks.length} active` });
if (postHooks.length > 0) hookRows.push({ label: "Post-unit", value: `${postHooks.length} active` });
sections.push({ title: "Hooks", rows: hookRows });
}
// ─── Warnings ────────────────────────────────────────────────────────
const warnings = [
...(globalPrefs?.warnings ?? []),
...(projectPrefs?.warnings ?? []),
];
if (warnings.length > 0) {
sections.push({
title: "Warnings",
rows: warnings.map(w => ({ label: "\u26a0", value: w })),
});
}
return sections;
}
// ─── Plain Text Formatter (headless/RPC fallback) ─────────────────────────
export function formatConfigText(): string {
const sections = collectConfigSections();
const lines: string[] = ["GSD Configuration\n"];
let maxLabel = 0;
for (const section of sections) {
for (const row of section.rows) {
if (row.label.length > maxLabel) maxLabel = row.label.length;
}
}
const pad = Math.min(maxLabel + 2, 24);
for (const section of sections) {
lines.push("");
lines.push(section.title.toUpperCase());
for (const row of section.rows) {
lines.push(` ${row.label.padEnd(pad)}${row.value}`);
}
}
return lines.join("\n");
}
// ─── Overlay Class ────────────────────────────────────────────────────────
export class GSDConfigOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
private onClose: () => void;
private sections: ConfigSection[];
private cachedLines?: string[];
private scrollOffset = 0;
private disposed = false;
constructor(
tui: { requestRender: () => void },
theme: Theme,
onClose: () => void,
) {
this.tui = tui;
this.theme = theme;
this.onClose = onClose;
this.sections = collectConfigSections();
}
invalidate(): void {
this.cachedLines = undefined;
}
dispose(): void {
this.disposed = true;
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || data === "q") {
this.dispose();
this.onClose();
return;
}
if (matchesKey(data, Key.down) || data === "j") {
this.scrollOffset++;
this.cachedLines = undefined;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.up) || data === "k") {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.cachedLines = undefined;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.pageDown)) {
this.scrollOffset += 10;
this.cachedLines = undefined;
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.pageUp)) {
this.scrollOffset = Math.max(0, this.scrollOffset - 10);
this.cachedLines = undefined;
this.tui.requestRender();
return;
}
}
render(width: number): string[] {
if (this.cachedLines) return this.cachedLines;
const t = this.theme;
const w = Math.max(width, 50);
const allLines: string[] = [];
// Header
allLines.push(t.bold(t.fg("accent", " GSD Configuration ")));
allLines.push(t.fg("muted", "\u2500".repeat(w)));
// Find max label width for alignment
let maxLabel = 0;
for (const section of this.sections) {
for (const row of section.rows) {
if (row.label.length > maxLabel) maxLabel = row.label.length;
}
}
const labelPad = Math.min(maxLabel + 2, 24);
for (const section of this.sections) {
allLines.push("");
allLines.push(t.bold(t.fg("accent", ` ${section.title}`)));
for (const row of section.rows) {
const label = t.fg("muted", ` ${row.label.padEnd(labelPad)}`);
const value = row.accent ? t.bold(row.value) : row.value;
allLines.push(truncateToWidth(`${label}${value}`, w));
}
}
allLines.push("");
allLines.push(t.fg("muted", ` ${"\u2500".repeat(w - 4)}`));
allLines.push(t.fg("muted", " esc/q close \u2502 \u2191\u2193/jk scroll \u2502 /gsd prefs to edit"));
// Apply scroll
const maxScroll = Math.max(0, allLines.length - 20);
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
const visible = allLines.slice(this.scrollOffset);
this.cachedLines = visible;
return visible;
}
}

View file

@ -0,0 +1,56 @@
/**
* /gsd show-config command structural tests.
*
* Verifies the config overlay class and command handler exist
* with correct structure.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const overlaySrc = readFileSync(join(__dirname, "..", "config-overlay.ts"), "utf-8");
const coreSrc = readFileSync(join(__dirname, "..", "commands", "handlers", "core.ts"), "utf-8");
// ─── Config overlay ───────────────────────────────────────────────────────
test("GSDConfigOverlay class is exported", () => {
assert.ok(
overlaySrc.includes("export class GSDConfigOverlay"),
"GSDConfigOverlay should be exported",
);
});
test("GSDConfigOverlay implements Component interface methods", () => {
assert.ok(overlaySrc.includes("render("), "should have render method");
assert.ok(overlaySrc.includes("handleInput("), "should have handleInput method");
assert.ok(overlaySrc.includes("invalidate("), "should have invalidate method");
assert.ok(overlaySrc.includes("dispose("), "should have dispose method");
});
test("formatConfigText function is exported", () => {
assert.ok(
overlaySrc.includes("export function formatConfigText"),
"formatConfigText should be exported for non-overlay fallback",
);
});
// ─── Command handler ──────────────────────────────────────────────────────
test("core handler routes show-config command", () => {
assert.ok(
coreSrc.includes('"show-config"'),
"core handler should match show-config command",
);
});
test("show-config has text fallback via formatConfigText", () => {
assert.ok(
coreSrc.includes("formatConfigText"),
"show-config should use formatConfigText as fallback",
);
});