diff --git a/src/resources/extensions/gsd/health-widget-core.ts b/src/resources/extensions/gsd/health-widget-core.ts new file mode 100644 index 000000000..46cd72eee --- /dev/null +++ b/src/resources/extensions/gsd/health-widget-core.ts @@ -0,0 +1,129 @@ +/** + * Pure GSD health widget logic. + * + * Separates project-state detection and line rendering from the widget's + * runtime integrations so the regressions can be tested directly. + */ + +import { existsSync, readdirSync } from "node:fs"; +import { gsdRoot } from "./paths.js"; +import { join } from "node:path"; +import type { GSDState, Phase } from "./types.js"; + +export type HealthWidgetProjectState = "none" | "initialized" | "active"; + +export interface HealthWidgetData { + projectState: HealthWidgetProjectState; + budgetCeiling: number | undefined; + budgetSpent: number; + providerIssue: string | null; + environmentErrorCount: number; + environmentWarningCount: number; + lastRefreshed: number; + executionPhase?: Phase; + executionStatus?: string; + executionTarget?: string; + nextAction?: string; + blocker?: string | null; + activeMilestoneId?: string; + activeSliceId?: string; + activeTaskId?: string; + progress?: GSDState["progress"]; + eta?: string | null; +} + +export function detectHealthWidgetProjectState(basePath: string): HealthWidgetProjectState { + const root = gsdRoot(basePath); + if (!existsSync(root)) return "none"; + + // Lightweight milestone count — avoids the full detectProjectState() scan + // (CI markers, Makefile targets, etc.) that is unnecessary on the 60s refresh. + try { + const milestonesDir = join(root, "milestones"); + if (existsSync(milestonesDir)) { + const entries = readdirSync(milestonesDir, { withFileTypes: true }); + if (entries.some(e => e.isDirectory())) return "active"; + } + } catch { /* non-fatal */ } + + return "initialized"; +} + +function formatCost(n: number): string { + return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; +} + +function formatProgress(progress?: GSDState["progress"]): string | null { + if (!progress) return null; + + const parts: string[] = []; + parts.push(`M ${progress.milestones.done}/${progress.milestones.total}`); + if (progress.slices) parts.push(`S ${progress.slices.done}/${progress.slices.total}`); + if (progress.tasks) parts.push(`T ${progress.tasks.done}/${progress.tasks.total}`); + return parts.length > 0 ? `Progress: ${parts.join(" · ")}` : null; +} + +function formatEnvironmentSummary(errorCount: number, warningCount: number): string | null { + if (errorCount <= 0 && warningCount <= 0) return null; + + const parts: string[] = []; + if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`); + if (warningCount > 0) parts.push(`${warningCount} warning${warningCount > 1 ? "s" : ""}`); + return `Env: ${parts.join(", ")}`; +} + +function formatBudgetSummary(data: HealthWidgetData): string | null { + if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { + const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); + return `Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`; + } + if (data.budgetSpent > 0) { + return `Spent: ${formatCost(data.budgetSpent)}`; + } + return null; +} + +function buildExecutionHeadline(data: HealthWidgetData): string { + const status = data.executionStatus ?? "Active project"; + const target = data.executionTarget ?? data.blocker ?? "loading status…"; + return ` GSD ${status}${target ? ` - ${target}` : ""}`; +} + +/** + * Build compact health lines for the widget. + * Returns a string array suitable for setWidget(). + */ +export function buildHealthLines(data: HealthWidgetData): string[] { + if (data.projectState === "none") { + return [" GSD No project loaded — run /gsd to start"]; + } + + if (data.projectState === "initialized") { + return [" GSD Project initialized — run /gsd to continue setup"]; + } + + const lines = [buildExecutionHeadline(data)]; + const details: string[] = []; + + const progress = formatProgress(data.progress); + if (progress) details.push(progress); + + if (data.providerIssue) details.push(data.providerIssue); + + const environment = formatEnvironmentSummary( + data.environmentErrorCount, + data.environmentWarningCount, + ); + if (environment) details.push(environment); + + const budget = formatBudgetSummary(data); + if (budget) details.push(budget); + + if (data.eta) details.push(data.eta); + + if (details.length > 0) { + lines.push(` ${details.join(" │ ")}`); + } + + return lines; +} diff --git a/src/resources/extensions/gsd/health-widget.ts b/src/resources/extensions/gsd/health-widget.ts index 994e959b3..1121fa26f 100644 --- a/src/resources/extensions/gsd/health-widget.ts +++ b/src/resources/extensions/gsd/health-widget.ts @@ -9,41 +9,37 @@ */ import type { ExtensionContext } from "@gsd/pi-coding-agent"; +import type { GSDState } from "./types.js"; import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js"; import { runEnvironmentChecks } from "./doctor-environment.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js"; +import { describeNextUnit, estimateTimeRemaining, updateSliceProgressCache } from "./auto-dashboard.js"; import { projectRoot } from "./commands.js"; - -// ── Types ────────────────────────────────────────────────────────────────────── - -interface HealthWidgetData { - hasProject: boolean; - budgetCeiling: number | undefined; - budgetSpent: number; - providerIssue: string | null; // compact summary from summariseProviderIssues() - environmentErrorCount: number; - environmentWarningCount: number; - lastRefreshed: number; -} +import { deriveState, invalidateStateCache } from "./state.js"; +import { + buildHealthLines, + detectHealthWidgetProjectState, + type HealthWidgetData, +} from "./health-widget-core.js"; // ── Data loader ──────────────────────────────────────────────────────────────── -function loadHealthWidgetData(basePath: string): HealthWidgetData { - let hasProject = false; +function loadBaseHealthWidgetData(basePath: string): HealthWidgetData { let budgetCeiling: number | undefined; let budgetSpent = 0; let providerIssue: string | null = null; let environmentErrorCount = 0; let environmentWarningCount = 0; + const projectState = detectHealthWidgetProjectState(basePath); + try { const prefs = loadEffectiveGSDPreferences(); budgetCeiling = prefs?.preferences?.budget_ceiling; const ledger = loadLedgerFromDisk(basePath); if (ledger) { - hasProject = true; const totals = getProjectTotals(ledger.units ?? []); budgetSpent = totals.cost; } @@ -63,7 +59,7 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData { } catch { /* non-fatal */ } return { - hasProject, + projectState, budgetCeiling, budgetSpent, providerIssue, @@ -73,54 +69,88 @@ function loadHealthWidgetData(basePath: string): HealthWidgetData { }; } -// ── Rendering ────────────────────────────────────────────────────────────────── - -function formatCost(n: number): string { - return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`; +function compactText(text: string, max = 64): string { + const trimmed = text.replace(/\s+/g, " ").trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max - 1).trimEnd()}…`; } -/** - * Build compact health lines for the widget. - * Returns a string array suitable for setWidget(). - */ -export function buildHealthLines(data: HealthWidgetData): string[] { - if (!data.hasProject) { - return [" GSD No project loaded — run /gsd to start"]; +function summarizeExecutionStatus(state: GSDState): string { + switch (state.phase) { + case "blocked": return "Blocked"; + case "paused": return "Paused"; + case "complete": return "Complete"; + case "executing": return "Executing"; + case "planning": return "Planning"; + case "pre-planning": return "Pre-planning"; + case "summarizing": return "Summarizing"; + case "validating-milestone": return "Validating"; + case "completing-milestone": return "Completing"; + case "needs-discussion": return "Needs discussion"; + case "replanning-slice": return "Replanning"; + default: return "Active"; } +} - const parts: string[] = []; - - // System status signal - const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0); - if (totalIssues === 0) { - parts.push("● System OK"); - } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) { - parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`); - } else { - parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`); +function summarizeExecutionTarget(state: GSDState): string { + switch (state.phase) { + case "needs-discussion": + return state.activeMilestone ? `Discuss ${state.activeMilestone.id}` : "Discuss milestone draft"; + case "pre-planning": + return state.activeMilestone ? `Plan ${state.activeMilestone.id}` : "Research & plan milestone"; + case "planning": + return state.activeSlice ? `Plan ${state.activeSlice.id}` : "Plan next slice"; + case "executing": + return state.activeTask ? `Execute ${state.activeTask.id}` : "Execute next task"; + case "summarizing": + return state.activeSlice ? `Complete ${state.activeSlice.id}` : "Complete current slice"; + case "validating-milestone": + return state.activeMilestone ? `Validate ${state.activeMilestone.id}` : "Validate milestone"; + case "completing-milestone": + return state.activeMilestone ? `Complete ${state.activeMilestone.id}` : "Complete milestone"; + case "replanning-slice": + return state.activeSlice ? `Replan ${state.activeSlice.id}` : "Replan current slice"; + case "blocked": + return `waiting on ${compactText(state.blockers[0] ?? state.nextAction, 56)}`; + case "paused": + return compactText(state.nextAction || "waiting to resume", 56); + case "complete": + return "All milestones complete"; + default: + return compactText(describeNextUnit(state).label, 56); } +} - // Budget - if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) { - const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100); - parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`); - } else if (data.budgetSpent > 0) { - parts.push(`Spent: ${formatCost(data.budgetSpent)}`); +async function enrichHealthWidgetData(basePath: string, baseData: HealthWidgetData): Promise { + if (baseData.projectState !== "active") return baseData; + + try { + invalidateStateCache(); + const state = await deriveState(basePath); + + if (state.activeMilestone) { + // Warm the slice-progress cache so estimateTimeRemaining() has data + updateSliceProgressCache(basePath, state.activeMilestone.id, state.activeSlice?.id); + } + + return { + ...baseData, + executionPhase: state.phase, + executionStatus: summarizeExecutionStatus(state), + executionTarget: summarizeExecutionTarget(state), + nextAction: state.nextAction, + blocker: state.blockers[0] ?? null, + activeMilestoneId: state.activeMilestone?.id, + activeSliceId: state.activeSlice?.id, + activeTaskId: state.activeTask?.id, + progress: state.progress, + eta: state.phase === "blocked" || state.phase === "paused" || state.phase === "complete" + ? null + : estimateTimeRemaining(), + }; + } catch { + return baseData; } - - // Provider issue (if any) - if (data.providerIssue) { - parts.push(data.providerIssue); - } - - // Environment issues - if (data.environmentErrorCount > 0) { - parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`); - } else if (data.environmentWarningCount > 0) { - parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`); - } - - return [` ${parts.join(" │ ")}`]; } // ── Widget init ──────────────────────────────────────────────────────────────── @@ -137,20 +167,34 @@ export function initHealthWidget(ctx: ExtensionContext): void { const basePath = projectRoot(); // String-array fallback — used in RPC mode (factory is a no-op there) - const initialData = loadHealthWidgetData(basePath); + const initialData = loadBaseHealthWidgetData(basePath); ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" }); // Factory-based widget for TUI mode — replaces the string-array above ctx.ui.setWidget("gsd-health", (_tui, _theme) => { let data = initialData; let cachedLines: string[] | undefined; + let refreshInFlight = false; - const refreshTimer = setInterval(() => { + const refresh = async () => { + if (refreshInFlight) return; + refreshInFlight = true; try { - data = loadHealthWidgetData(basePath); + const baseData = loadBaseHealthWidgetData(basePath); + data = await enrichHealthWidgetData(basePath, baseData); cachedLines = undefined; _tui.requestRender(); - } catch { /* non-fatal */ } + } catch { /* non-fatal */ } finally { + refreshInFlight = false; + } + }; + + // Fire first enrichment immediately. requestRender() inside is a no-op + // if the widget has not yet rendered, so this is safe before factory return. + void refresh(); + + const refreshTimer = setInterval(() => { + void refresh(); }, REFRESH_INTERVAL_MS); return { diff --git a/src/resources/extensions/gsd/tests/health-widget.test.ts b/src/resources/extensions/gsd/tests/health-widget.test.ts new file mode 100644 index 000000000..3c9df9498 --- /dev/null +++ b/src/resources/extensions/gsd/tests/health-widget.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildHealthLines, + detectHealthWidgetProjectState, + type HealthWidgetData, +} from "../health-widget-core.ts"; + +function makeTempDir(prefix: string): string { + const dir = join( + tmpdir(), + `gsd-health-widget-test-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort + } +} + +function activeData(overrides: Partial = {}): HealthWidgetData { + return { + projectState: "active", + budgetCeiling: undefined, + budgetSpent: 0, + providerIssue: null, + environmentErrorCount: 0, + environmentWarningCount: 0, + lastRefreshed: Date.now(), + ...overrides, + }; +} + +test("detectHealthWidgetProjectState: no .gsd returns none", () => { + const dir = makeTempDir("none"); + try { + assert.equal(detectHealthWidgetProjectState(dir), "none"); + } finally { + cleanup(dir); + } +}); + +test("detectHealthWidgetProjectState: bootstrapped .gsd without milestones returns initialized", () => { + const dir = makeTempDir("initialized"); + try { + mkdirSync(join(dir, ".gsd"), { recursive: true }); + assert.equal(detectHealthWidgetProjectState(dir), "initialized"); + } finally { + cleanup(dir); + } +}); + +test("detectHealthWidgetProjectState: milestone without metrics returns active", () => { + const dir = makeTempDir("active"); + try { + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + assert.equal(detectHealthWidgetProjectState(dir), "active"); + } finally { + cleanup(dir); + } +}); + +test("buildHealthLines: none state shows onboarding copy", () => { + assert.deepEqual(buildHealthLines(activeData({ projectState: "none" })), [ + " GSD No project loaded — run /gsd to start", + ]); +}); + +test("buildHealthLines: initialized state shows continue setup copy", () => { + assert.deepEqual(buildHealthLines(activeData({ projectState: "initialized" })), [ + " GSD Project initialized — run /gsd to continue setup", + ]); +}); + +test("buildHealthLines: active state leads with execution summary", () => { + const lines = buildHealthLines(activeData({ + executionStatus: "Executing", + executionTarget: "Plan S01", + progress: { + milestones: { done: 0, total: 1 }, + slices: { done: 0, total: 3 }, + tasks: { done: 0, total: 5 }, + }, + })); + + assert.equal(lines.length, 2); + assert.equal(lines[0], " GSD Executing - Plan S01"); + assert.match(lines[1]!, /Progress: M 0\/1 · S 0\/3 · T 0\/5/); +}); + +test("buildHealthLines: active state keeps issues secondary", () => { + const lines = buildHealthLines(activeData({ + executionStatus: "Planning", + executionTarget: "Execute T03", + providerIssue: "✗ Anthropic (Claude) key missing", + environmentWarningCount: 1, + budgetSpent: 0.42, + })); + + assert.equal(lines.length, 2); + assert.equal(lines[0], " GSD Planning - Execute T03"); + assert.match(lines[1]!, /✗ Anthropic \(Claude\) key missing/); + assert.match(lines[1]!, /Env: 1 warning/); + assert.match(lines[1]!, /Spent: 42\.0¢/); +}); + +test("buildHealthLines: blocked state explains wait reason", () => { + const lines = buildHealthLines(activeData({ + executionStatus: "Blocked", + executionTarget: "waiting on unmet deps: M001", + blocker: "M002 is waiting on unmet deps: M001", + })); + + assert.equal(lines[0], " GSD Blocked - waiting on unmet deps: M001"); +}); + +test("buildHealthLines: paused state can omit secondary line", () => { + const lines = buildHealthLines(activeData({ + executionStatus: "Paused", + executionTarget: "waiting to resume", + })); + + assert.deepEqual(lines, [" GSD Paused - waiting to resume"]); +}); + +test("buildHealthLines: active state with budget ceiling shows percent summary", () => { + const lines = buildHealthLines(activeData({ + executionStatus: "Executing", + executionTarget: "Plan S01", + budgetSpent: 2.5, + budgetCeiling: 10, + })); + assert.equal(lines.length, 2); + assert.match(lines[1]!, /Budget: \$2\.50\/\$10\.00 \(25%\)/); +}); + +test("detectHealthWidgetProjectState: metrics file alone does not imply project", () => { + const dir = makeTempDir("metrics-only"); + try { + mkdirSync(join(dir, ".gsd"), { recursive: true }); + writeFileSync( + join(dir, ".gsd", "metrics.json"), + JSON.stringify({ version: 1, projectStartedAt: Date.now(), units: [] }), + "utf-8", + ); + assert.equal(detectHealthWidgetProjectState(dir), "initialized"); + } finally { + cleanup(dir); + } +});