From ee14135d6c49433bf045dccbcde891e28de93cf4 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 10:23:39 -0500 Subject: [PATCH] feat: expand workflow visualizer with 8 new features (7-tab overlay) (#636) * feat: add workflow visualizer TUI overlay with 4-tab interactive view Add `/gsd visualize` command that opens a full-screen TUI overlay with four tabs: Progress (milestone/slice/task tree), Dependencies (ASCII dep graph), Metrics (cost/token bar charts), and Timeline (chronological execution history). Supports Tab/1-4 switching, per-tab scrolling, and auto-refresh every 2s. Opt-in auto-trigger hint after milestone completion via `auto_visualize` preference. New files: - visualizer-data.ts: async data loader aggregating state + metrics - visualizer-views.ts: 4 pure view renderers - visualizer-overlay.ts: overlay class with tab/scroll/cache management - tests/visualizer-views.test.ts: 21 assertions on view renderers - tests/visualizer-data.test.ts: 33 source contract assertions Modified: - commands.ts: register "visualize" subcommand + handler - auto.ts: milestone completion hint when auto_visualize enabled - preferences.ts: add auto_visualize preference key * feat: expand workflow visualizer with 8 new features across 7 tabs Add critical path analysis, risk heatmap, cost projections, Gantt timeline, live agent activity, diff/changelog, search/filter, and export capabilities to the workflow visualizer overlay. - Critical path: O(V+E) topological sort + longest path algorithm with slack computation for milestones and slices - Risk heatmap: colored block grid with legend and summary counts - Cost projections: avg cost/slice, burn rate, sparkline, budget warnings - Gantt timeline: horizontal bars with phase coloring and time axis (falls back to list view on narrow terminals) - Agent activity: real-time status, progress bar, completion rate - Changelog: parsed SUMMARY files with mtime-based caching - Search/filter: / enters filter mode, f cycles field, supports keyword/status/risk filtering - Export: standalone writeExportFile() + m/j/s keys for markdown/JSON/snapshot export from overlay Tab bar expanded from 4 to 7 tabs. 146 new test assertions across 4 test files. All 604 tests pass with zero regressions. * fix: update help text to reflect 7-tab visualizer --- src/resources/extensions/gsd/commands.ts | 2 +- src/resources/extensions/gsd/export.ts | 82 ++- .../tests/visualizer-critical-path.test.ts | 145 ++++++ .../gsd/tests/visualizer-data.test.ts | 92 ++++ .../gsd/tests/visualizer-overlay.test.ts | 120 +++++ .../gsd/tests/visualizer-views.test.ts | 231 ++++++++- .../extensions/gsd/visualizer-data.ts | 353 ++++++++++++- .../extensions/gsd/visualizer-overlay.ts | 190 ++++++- .../extensions/gsd/visualizer-views.ts | 466 +++++++++++++++++- 9 files changed, 1648 insertions(+), 33 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts create mode 100644 src/resources/extensions/gsd/tests/visualizer-overlay.test.ts diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index cc81f6ae4..0cc721314 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -369,7 +369,7 @@ function showHelp(ctx: ExtensionCommandContext): void { "", "VISIBILITY", " /gsd status Show progress dashboard (Ctrl+Alt+G)", - " /gsd visualize Interactive tree visualizer with 4-tab TUI", + " /gsd visualize Interactive 7-tab TUI (progress, deps, metrics, timeline, agent, changes, export)", " /gsd queue Show queued/dispatched units and execution order", " /gsd history View execution history [--cost] [--phase] [--model] [N]", "", diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts index d799da718..1d8671139 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -7,12 +7,92 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { join, basename } from "node:path"; import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, - aggregateByModel, formatCost, formatTokenCount, + aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk, } from "./metrics.js"; import type { UnitMetrics } from "./metrics.js"; import { gsdRoot } from "./paths.js"; import { formatDuration } from "./history.js"; +/** + * Write an export file directly, without requiring an ExtensionCommandContext. + * Used by the visualizer overlay export tab. + * Returns the output file path, or null on failure. + */ +export function writeExportFile( + basePath: string, + format: "markdown" | "json", + visualizerData?: { totals: any; byPhase: any[]; bySlice: any[]; byModel: any[]; units: any[]; criticalPath?: any; remainingSliceCount?: number }, +): string | null { + const ledger = getLedger(); + let units: UnitMetrics[]; + + if (visualizerData && visualizerData.units.length > 0) { + units = visualizerData.units; + } else if (ledger && ledger.units.length > 0) { + units = ledger.units; + } else { + const diskLedger = loadLedgerFromDisk(basePath); + if (!diskLedger || diskLedger.units.length === 0) return null; + units = diskLedger.units; + } + + const projectName = basename(basePath); + const exportDir = gsdRoot(basePath); + mkdirSync(exportDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + + if (format === "json") { + const report = { + exportedAt: new Date().toISOString(), + project: projectName, + totals: visualizerData?.totals ?? getProjectTotals(units), + byPhase: visualizerData?.byPhase ?? aggregateByPhase(units), + bySlice: visualizerData?.bySlice ?? aggregateBySlice(units), + byModel: visualizerData?.byModel ?? aggregateByModel(units), + units, + }; + const outPath = join(exportDir, `export-${timestamp}.json`); + writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8"); + return outPath; + } else { + const totals = visualizerData?.totals ?? getProjectTotals(units); + const phases = visualizerData?.byPhase ?? aggregateByPhase(units); + const slices = visualizerData?.bySlice ?? aggregateBySlice(units); + + const md = [ + `# GSD Session Report — ${projectName}`, + ``, + `**Generated**: ${new Date().toISOString()}`, + `**Units completed**: ${totals.units}`, + `**Total cost**: ${formatCost(totals.cost)}`, + `**Total tokens**: ${formatTokenCount(totals.tokens.total)}`, + `**Total duration**: ${formatDuration(totals.duration)}`, + `**Tool calls**: ${totals.toolCalls}`, + ``, + `## Cost by Phase`, + ``, + `| Phase | Units | Cost | Tokens | Duration |`, + `|-------|-------|------|--------|----------|`, + ...phases.map((p: any) => + `| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`, + ), + ``, + `## Cost by Slice`, + ``, + `| Slice | Units | Cost | Tokens | Duration |`, + `|-------|-------|------|--------|----------|`, + ...slices.map((s: any) => + `| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`, + ), + ``, + ].join("\n"); + + const outPath = join(exportDir, `export-${timestamp}.md`); + writeFileSync(outPath, md, "utf-8"); + return outPath; + } +} + /** * Export session/milestone data to JSON or markdown. */ diff --git a/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts b/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts new file mode 100644 index 000000000..520e488fa --- /dev/null +++ b/src/resources/extensions/gsd/tests/visualizer-critical-path.test.ts @@ -0,0 +1,145 @@ +// Tests for critical path algorithm. +// Tests computeCriticalPath with known DAG structures. + +import { computeCriticalPath } from "../visualizer-data.js"; +import type { VisualizerMilestone } from "../visualizer-data.js"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function makeMs(id: string, status: "complete" | "active" | "pending", dependsOn: string[], slices: any[] = []): VisualizerMilestone { + return { id, title: id, status, dependsOn, slices }; +} + +function makeSlice(id: string, done: boolean, depends: string[] = []) { + return { id, title: id, done, active: false, risk: "low", depends, tasks: [] }; +} + +// ─── Linear chain ─────────────────────────────────────────────────────────── + +console.log("\n=== Critical Path: Linear Chain ==="); + +{ + // M001 -> M002 -> M003 + const milestones = [ + makeMs("M001", "complete", []), + makeMs("M002", "active", ["M001"], [ + makeSlice("S01", true), + makeSlice("S02", false, ["S01"]), + ]), + makeMs("M003", "pending", ["M002"]), + ]; + + const cp = computeCriticalPath(milestones); + assertTrue(cp.milestonePath.length > 0, "linear chain has critical path"); + assertTrue(cp.milestonePath.includes("M002"), "M002 is on critical path"); + assertTrue(cp.milestonePath.includes("M003"), "M003 is on critical path"); + assertEq(cp.milestoneSlack.get("M002"), 0, "M002 has zero slack"); + assertEq(cp.milestoneSlack.get("M003"), 0, "M003 has zero slack"); +} + +// ─── Diamond DAG ──────────────────────────────────────────────────────────── + +console.log("\n=== Critical Path: Diamond DAG ==="); + +{ + // M001 -> M002 -> M004 + // M001 -> M003 -> M004 + // M002 has 3 incomplete slices, M003 has 1 incomplete slice + const milestones = [ + makeMs("M001", "complete", []), + makeMs("M002", "active", ["M001"], [ + makeSlice("S01", false), + makeSlice("S02", false), + makeSlice("S03", false), + ]), + makeMs("M003", "pending", ["M001"], [ + makeSlice("S01", false), + ]), + makeMs("M004", "pending", ["M002", "M003"]), + ]; + + const cp = computeCriticalPath(milestones); + assertTrue(cp.milestonePath.length >= 2, "diamond DAG has critical path"); + // M002 has weight 3 (3 incomplete), M003 has weight 1 + // Critical path should go through M002 (longer) + assertTrue(cp.milestonePath.includes("M002"), "M002 (heavier) is on critical path"); + + // M003 should have non-zero slack since it's lighter + const m003Slack = cp.milestoneSlack.get("M003") ?? -1; + assertTrue(m003Slack > 0, "M003 has positive slack (lighter branch)"); +} + +// ─── Independent branches ─────────────────────────────────────────────────── + +console.log("\n=== Critical Path: Independent Branches ==="); + +{ + // M001 (no deps), M002 (no deps), M003 (no deps) + const milestones = [ + makeMs("M001", "active", [], [makeSlice("S01", false)]), + makeMs("M002", "pending", [], [makeSlice("S01", false), makeSlice("S02", false)]), + makeMs("M003", "pending", [], [makeSlice("S01", false)]), + ]; + + const cp = computeCriticalPath(milestones); + assertTrue(cp.milestonePath.length >= 1, "independent branches have at least one critical node"); + // M002 has the most incomplete slices, should be critical + assertTrue(cp.milestonePath.includes("M002"), "M002 (longest) is on critical path"); +} + +// ─── Slice-level critical path ────────────────────────────────────────────── + +console.log("\n=== Critical Path: Slice-level ==="); + +{ + // Active milestone with slice dependencies: S01 -> S02 -> S04, S01 -> S03 + const milestones = [ + makeMs("M001", "active", [], [ + makeSlice("S01", true), + makeSlice("S02", false, ["S01"]), + makeSlice("S03", false, ["S01"]), + makeSlice("S04", false, ["S02"]), + ]), + ]; + + const cp = computeCriticalPath(milestones); + assertTrue(cp.slicePath.length > 0, "has slice-level critical path"); + assertTrue(cp.slicePath.includes("S02"), "S02 is on slice critical path"); + assertTrue(cp.slicePath.includes("S04"), "S04 is on slice critical path"); + + // S03 should have non-zero slack (it's a shorter branch) + const s03Slack = cp.sliceSlack.get("S03") ?? -1; + assertTrue(s03Slack > 0, "S03 has positive slack (shorter branch)"); +} + +// ─── Empty milestones ─────────────────────────────────────────────────────── + +console.log("\n=== Critical Path: Empty ==="); + +{ + const cp = computeCriticalPath([]); + assertEq(cp.milestonePath.length, 0, "empty milestones produce empty path"); + assertEq(cp.slicePath.length, 0, "empty milestones produce empty slice path"); +} + +// ─── Single milestone ─────────────────────────────────────────────────────── + +console.log("\n=== Critical Path: Single Milestone ==="); + +{ + const milestones = [ + makeMs("M001", "active", [], [ + makeSlice("S01", false), + makeSlice("S02", false), + ]), + ]; + + const cp = computeCriticalPath(milestones); + assertTrue(cp.milestonePath.length === 1, "single milestone is its own critical path"); + assertEq(cp.milestonePath[0], "M001", "M001 is the critical node"); +} + +// ─── Report ───────────────────────────────────────────────────────────────── + +report(); diff --git a/src/resources/extensions/gsd/tests/visualizer-data.test.ts b/src/resources/extensions/gsd/tests/visualizer-data.test.ts index 3545630d6..3aec834e1 100644 --- a/src/resources/extensions/gsd/tests/visualizer-data.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-data.test.ts @@ -35,12 +35,38 @@ assertTrue( "exports VisualizerTask interface", ); +// New interfaces +assertTrue( + dataSrc.includes("export interface CriticalPathInfo"), + "exports CriticalPathInfo interface", +); + +assertTrue( + dataSrc.includes("export interface AgentActivityInfo"), + "exports AgentActivityInfo interface", +); + +assertTrue( + dataSrc.includes("export interface ChangelogEntry"), + "exports ChangelogEntry interface", +); + +assertTrue( + dataSrc.includes("export interface ChangelogInfo"), + "exports ChangelogInfo interface", +); + // Function export assertTrue( dataSrc.includes("export async function loadVisualizerData"), "exports loadVisualizerData function", ); +assertTrue( + dataSrc.includes("export function computeCriticalPath"), + "exports computeCriticalPath function", +); + // Data source usage assertTrue( dataSrc.includes("deriveState"), @@ -62,6 +88,11 @@ assertTrue( "uses parsePlan for plan parsing", ); +assertTrue( + dataSrc.includes("parseSummary"), + "uses parseSummary for changelog parsing", +); + assertTrue( dataSrc.includes("getLedger"), "uses getLedger for in-memory metrics", @@ -113,6 +144,27 @@ assertTrue( "VisualizerData has units array", ); +// New data model fields +assertTrue( + dataSrc.includes("criticalPath: CriticalPathInfo"), + "VisualizerData has criticalPath field", +); + +assertTrue( + dataSrc.includes("remainingSliceCount: number"), + "VisualizerData has remainingSliceCount field", +); + +assertTrue( + dataSrc.includes("agentActivity: AgentActivityInfo | null"), + "VisualizerData has agentActivity field", +); + +assertTrue( + dataSrc.includes("changelog: ChangelogInfo"), + "VisualizerData has changelog field", +); + // Verify overlay source exists and imports data module const overlayPath = join(__dirname, "..", "visualizer-overlay.ts"); const overlaySrc = readFileSync(overlayPath, "utf-8"); @@ -149,6 +201,21 @@ assertTrue( "overlay delegates to renderTimelineView", ); +assertTrue( + overlaySrc.includes("renderAgentView"), + "overlay delegates to renderAgentView", +); + +assertTrue( + overlaySrc.includes("renderChangelogView"), + "overlay delegates to renderChangelogView", +); + +assertTrue( + overlaySrc.includes("renderExportView"), + "overlay delegates to renderExportView", +); + assertTrue( overlaySrc.includes("handleInput"), "overlay has handleInput method", @@ -174,6 +241,31 @@ assertTrue( "overlay tracks per-tab scroll offsets", ); +assertTrue( + overlaySrc.includes("filterMode"), + "overlay has filterMode state", +); + +assertTrue( + overlaySrc.includes("filterText"), + "overlay has filterText state", +); + +assertTrue( + overlaySrc.includes("filterField"), + "overlay has filterField state", +); + +assertTrue( + overlaySrc.includes("TAB_COUNT"), + "overlay defines TAB_COUNT", +); + +assertTrue( + overlaySrc.includes("7 Export"), + "overlay has 7 tab labels", +); + // Verify commands.ts integration const commandsPath = join(__dirname, "..", "commands.ts"); const commandsSrc = readFileSync(commandsPath, "utf-8"); diff --git a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts new file mode 100644 index 000000000..cb6bb89af --- /dev/null +++ b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts @@ -0,0 +1,120 @@ +// Tests for GSD visualizer overlay. +// Verifies filter mode, tab switching, and export key handling. + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestContext } from "./test-helpers.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const { assertTrue, assertEq, report } = createTestContext(); + +const overlaySrc = readFileSync(join(__dirname, "..", "visualizer-overlay.ts"), "utf-8"); + +console.log("\n=== Overlay: Tab Configuration ==="); + +assertTrue( + overlaySrc.includes("TAB_COUNT = 7"), + "TAB_COUNT is 7", +); + +assertTrue( + overlaySrc.includes('"1 Progress"'), + "has Progress tab label", +); + +assertTrue( + overlaySrc.includes('"5 Agent"'), + "has Agent tab label", +); + +assertTrue( + overlaySrc.includes('"6 Changes"'), + "has Changes tab label", +); + +assertTrue( + overlaySrc.includes('"7 Export"'), + "has Export tab label", +); + +console.log("\n=== Overlay: Filter Mode ==="); + +assertTrue( + overlaySrc.includes('filterMode = false'), + "filterMode initialized to false", +); + +assertTrue( + overlaySrc.includes('filterText = ""'), + "filterText initialized to empty string", +); + +assertTrue( + overlaySrc.includes('filterField:'), + "has filterField state", +); + +// Filter mode entry via "/" +assertTrue( + overlaySrc.includes('data === "/"') || overlaySrc.includes("data === '/'"), + "/ key enters filter mode", +); + +// Filter field cycling via "f" +assertTrue( + overlaySrc.includes('data === "f"') || overlaySrc.includes("data === 'f'"), + "f key cycles filter field", +); + +console.log("\n=== Overlay: Tab Switching ==="); + +// Supports 1-7 keys +assertTrue( + overlaySrc.includes('"1234567"'), + "supports keys 1-7 for tab switching", +); + +// Tab wraps with TAB_COUNT +assertTrue( + overlaySrc.includes("% TAB_COUNT"), + "tab key wraps around TAB_COUNT", +); + +console.log("\n=== Overlay: Export Key Interception ==="); + +assertTrue( + overlaySrc.includes("activeTab === 6"), + "export key handling checks for tab 7 (index 6)", +); + +assertTrue( + overlaySrc.includes('handleExportKey'), + "has handleExportKey method", +); + +assertTrue( + overlaySrc.includes('"m"') && overlaySrc.includes('"j"') && overlaySrc.includes('"s"'), + "handles m, j, s keys for export", +); + +console.log("\n=== Overlay: Footer ==="); + +assertTrue( + overlaySrc.includes("Tab/1-7"), + "footer hint shows 1-7 tab range", +); + +assertTrue( + overlaySrc.includes("/ filter"), + "footer hint mentions filter", +); + +console.log("\n=== Overlay: Scroll Offsets ==="); + +assertTrue( + overlaySrc.includes(`new Array(TAB_COUNT).fill(0)`), + "scroll offsets sized to TAB_COUNT", +); + +report(); diff --git a/src/resources/extensions/gsd/tests/visualizer-views.test.ts b/src/resources/extensions/gsd/tests/visualizer-views.test.ts index 8bf5cb78d..580a21475 100644 --- a/src/resources/extensions/gsd/tests/visualizer-views.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-views.test.ts @@ -6,6 +6,9 @@ import { renderDepsView, renderMetricsView, renderTimelineView, + renderAgentView, + renderChangelogView, + renderExportView, } from "../visualizer-views.js"; import type { VisualizerData } from "../visualizer-data.js"; import { createTestContext } from "./test-helpers.ts"; @@ -30,6 +33,15 @@ function makeVisualizerData(overrides: Partial = {}): Visualizer bySlice: [], byModel: [], units: [], + criticalPath: { + milestonePath: [], + slicePath: [], + milestoneSlack: new Map(), + sliceSlack: new Map(), + }, + remainingSliceCount: 0, + agentActivity: null, + changelog: { entries: [] }, ...overrides, }; } @@ -104,6 +116,73 @@ console.log("\n=== renderProgressView ==="); assertEq(lines.length, 0, "empty milestones produce no lines"); } +// ─── Risk Heatmap ─────────────────────────────────────────────────────────── + +console.log("\n=== Risk Heatmap ==="); + +{ + const data = makeVisualizerData({ + milestones: [ + { + id: "M001", + title: "First", + status: "active", + dependsOn: [], + slices: [ + { id: "S01", title: "A", done: true, active: false, risk: "low", depends: [], tasks: [] }, + { id: "S02", title: "B", done: false, active: true, risk: "high", depends: [], tasks: [] }, + { id: "S03", title: "C", done: false, active: false, risk: "medium", depends: [], tasks: [] }, + { id: "S04", title: "D", done: false, active: false, risk: "high", depends: [], tasks: [] }, + ], + }, + ], + }); + + const lines = renderProgressView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("Risk Heatmap")), "heatmap header present"); + assertTrue(lines.some(l => l.includes("██")), "heatmap has colored blocks"); + assertTrue(lines.some(l => l.includes("low") && l.includes("med") && l.includes("high")), "heatmap legend present"); + assertTrue(lines.some(l => l.includes("1 low, 1 med, 2 high")), "risk summary counts"); + assertTrue(lines.some(l => l.includes("1 high-risk not started")), "high-risk not started warning"); +} + +// ─── Search/Filter ────────────────────────────────────────────────────────── + +console.log("\n=== Search/Filter ==="); + +{ + const data = makeVisualizerData({ + milestones: [ + { + id: "M001", + title: "Auth", + status: "active", + dependsOn: [], + slices: [ + { id: "S01", title: "JWT", done: false, active: false, risk: "low", depends: [], tasks: [] }, + { id: "S02", title: "OAuth", done: false, active: false, risk: "high", depends: [], tasks: [] }, + ], + }, + { + id: "M002", + title: "Dashboard", + status: "pending", + dependsOn: ["M001"], + slices: [], + }, + ], + }); + + // Filter by keyword "auth" + const filtered = renderProgressView(data, mockTheme, 80, { text: "auth", field: "all" }); + assertTrue(filtered.some(l => l.includes("M001")), "filter shows matching milestone"); + assertTrue(filtered.some(l => l.includes("Filter (all): auth")), "filter indicator present"); + + // Filter by risk "high" + const riskFiltered = renderProgressView(data, mockTheme, 80, { text: "high", field: "risk" }); + assertTrue(riskFiltered.some(l => l.includes("M001")), "risk filter shows milestone with high-risk slice"); +} + // ─── renderDepsView ───────────────────────────────────────────────────────── console.log("\n=== renderDepsView ==="); @@ -129,12 +208,20 @@ console.log("\n=== renderDepsView ==="); slices: [], }, ], + criticalPath: { + milestonePath: ["M001", "M002"], + slicePath: ["S01", "S02"], + milestoneSlack: new Map([["M001", 0], ["M002", 0]]), + sliceSlack: new Map([["S01", 0], ["S02", 0]]), + }, }); const lines = renderDepsView(data, mockTheme, 80); assertTrue(lines.length > 0, "deps view produces output"); assertTrue(lines.some(l => l.includes("M001") && l.includes("M002")), "shows milestone dep edge"); assertTrue(lines.some(l => l.includes("S01") && l.includes("S02")), "shows slice dep edge"); + assertTrue(lines.some(l => l.includes("Critical Path")), "shows critical path section"); + assertTrue(lines.some(l => l.includes("[CRITICAL]")), "shows CRITICAL badge"); } { @@ -187,6 +274,11 @@ console.log("\n=== renderMetricsView ==="); cost: 2.50, }, ], + bySlice: [ + { sliceId: "M001/S01", units: 3, tokens: { input: 600, output: 300, cacheRead: 100, cacheWrite: 50, total: 1050 }, cost: 1.50, duration: 40000 }, + { sliceId: "M001/S02", units: 2, tokens: { input: 400, output: 200, cacheRead: 100, cacheWrite: 50, total: 750 }, cost: 1.00, duration: 20000 }, + ], + remainingSliceCount: 3, }); const lines = renderMetricsView(data, mockTheme, 80); @@ -194,6 +286,11 @@ console.log("\n=== renderMetricsView ==="); assertTrue(lines.some(l => l.includes("$2.50")), "shows total cost"); assertTrue(lines.some(l => l.includes("execution")), "shows phase name"); assertTrue(lines.some(l => l.includes("claude-opus-4-6")), "shows model name"); + assertTrue(lines.some(l => l.includes("Projections")), "shows projections section"); + assertTrue(lines.some(l => l.includes("Avg cost/slice")), "shows avg cost per slice"); + assertTrue(lines.some(l => l.includes("Projected remaining")), "shows projected remaining"); + assertTrue(lines.some(l => l.includes("Burn rate")), "shows burn rate"); + assertTrue(lines.some(l => l.includes("Cost trend")), "shows sparkline"); } { @@ -237,11 +334,16 @@ console.log("\n=== renderTimelineView ==="); ], }); - const lines = renderTimelineView(data, mockTheme, 80); - assertTrue(lines.length >= 2, "timeline view produces lines for each unit"); - assertTrue(lines.some(l => l.includes("execute-task")), "shows unit type"); - assertTrue(lines.some(l => l.includes("M001/S01/T01")), "shows unit id"); - assertTrue(lines.some(l => l.includes("$0.42")), "shows unit cost"); + // Wide terminal — Gantt view + const ganttLines = renderTimelineView(data, mockTheme, 120); + assertTrue(ganttLines.length >= 2, "gantt view produces lines for each unit"); + + // Narrow terminal — list view + const listLines = renderTimelineView(data, mockTheme, 80); + assertTrue(listLines.length >= 2, "list view produces lines for each unit"); + assertTrue(listLines.some(l => l.includes("execute-task")), "shows unit type"); + assertTrue(listLines.some(l => l.includes("M001/S01/T01")), "shows unit id"); + assertTrue(listLines.some(l => l.includes("$0.42")), "shows unit cost"); } { @@ -250,6 +352,125 @@ console.log("\n=== renderTimelineView ==="); assertTrue(lines.some(l => l.includes("No execution history")), "shows empty message"); } +// ─── renderAgentView ──────────────────────────────────────────────────────── + +console.log("\n=== renderAgentView ==="); + +{ + const now = Date.now(); + const data = makeVisualizerData({ + agentActivity: { + currentUnit: { type: "execute-task", id: "M001/S02/T03", startedAt: now - 60000 }, + elapsed: 60000, + completedUnits: 8, + totalSlices: 15, + completionRate: 2.4, + active: true, + sessionCost: 1.23, + sessionTokens: 45200, + }, + units: [ + { + type: "execute-task", id: "M001/S01/T01", model: "claude-opus-4-6", + startedAt: now - 300000, finishedAt: now - 240000, + tokens: { input: 500, output: 200, cacheRead: 100, cacheWrite: 50, total: 850 }, + cost: 0.12, toolCalls: 5, assistantMessages: 3, userMessages: 1, + }, + ], + }); + + const lines = renderAgentView(data, mockTheme, 80); + assertTrue(lines.length > 0, "agent view produces output"); + assertTrue(lines.some(l => l.includes("ACTIVE")), "shows active status"); + assertTrue(lines.some(l => l.includes("M001/S02/T03")), "shows current unit"); + assertTrue(lines.some(l => l.includes("8/15")), "shows progress fraction"); + assertTrue(lines.some(l => l.includes("2.4 units/hr")), "shows completion rate"); + assertTrue(lines.some(l => l.includes("$1.23")), "shows session cost"); +} + +{ + const data = makeVisualizerData({ agentActivity: null }); + const lines = renderAgentView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No agent activity")), "shows no-activity message"); +} + +{ + const data = makeVisualizerData({ + agentActivity: { + currentUnit: null, + elapsed: 0, + completedUnits: 5, + totalSlices: 10, + completionRate: 1.5, + active: false, + sessionCost: 0.50, + sessionTokens: 20000, + }, + }); + + const lines = renderAgentView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("IDLE")), "shows idle status"); + assertTrue(lines.some(l => l.includes("Not in auto mode")), "shows not-in-auto message"); +} + +// ─── renderChangelogView ──────────────────────────────────────────────────── + +console.log("\n=== renderChangelogView ==="); + +{ + const data = makeVisualizerData({ + changelog: { + entries: [ + { + milestoneId: "M001", + sliceId: "S01", + title: "Core Authentication Setup", + oneLiner: "Added JWT-based auth with refresh token rotation", + filesModified: [ + { path: "src/auth/jwt.ts", description: "JWT token generation and validation" }, + { path: "src/auth/middleware.ts", description: "Express middleware for auth checks" }, + ], + completedAt: "2026-03-15T14:30:00Z", + }, + ], + }, + }); + + const lines = renderChangelogView(data, mockTheme, 80); + assertTrue(lines.length > 0, "changelog view produces output"); + assertTrue(lines.some(l => l.includes("M001/S01")), "shows slice reference"); + assertTrue(lines.some(l => l.includes("Core Authentication Setup")), "shows entry title"); + assertTrue(lines.some(l => l.includes("JWT-based auth")), "shows one-liner"); + assertTrue(lines.some(l => l.includes("src/auth/jwt.ts")), "shows modified file"); + assertTrue(lines.some(l => l.includes("2026-03-15")), "shows completed date"); +} + +{ + const data = makeVisualizerData({ changelog: { entries: [] } }); + const lines = renderChangelogView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No completed slices")), "shows empty state"); +} + +// ─── renderExportView ─────────────────────────────────────────────────────── + +console.log("\n=== renderExportView ==="); + +{ + const data = makeVisualizerData(); + const lines = renderExportView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("Export Options")), "shows export header"); + assertTrue(lines.some(l => l.includes("[m]")), "shows markdown option"); + assertTrue(lines.some(l => l.includes("[j]")), "shows json option"); + assertTrue(lines.some(l => l.includes("[s]")), "shows snapshot option"); +} + +{ + const data = makeVisualizerData(); + const lines = renderExportView(data, mockTheme, 80, "/tmp/export-2026.md"); + assertTrue(lines.some(l => l.includes("Last export:")), "shows last export path"); + assertTrue(lines.some(l => l.includes("/tmp/export-2026.md")), "shows specific export path"); +} + // ─── Report ───────────────────────────────────────────────────────────────── report(); diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index 74936789d..5abf82e01 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -1,7 +1,7 @@ // Data loader for workflow visualizer overlay — aggregates state + metrics. import { deriveState } from './state.js'; -import { parseRoadmap, parsePlan, loadFile } from './files.js'; +import { parseRoadmap, parsePlan, parseSummary, loadFile } from './files.js'; import { findMilestoneIds } from './guided-flow.js'; import { resolveMilestoneFile, resolveSliceFile } from './paths.js'; import { @@ -11,6 +11,7 @@ import { aggregateBySlice, aggregateByModel, loadLedgerFromDisk, + classifyUnitPhase, } from './metrics.js'; import type { Phase } from './types.js'; @@ -49,6 +50,37 @@ export interface VisualizerTask { active: boolean; } +export interface CriticalPathInfo { + milestonePath: string[]; + slicePath: string[]; + milestoneSlack: Map; + sliceSlack: Map; +} + +export interface AgentActivityInfo { + currentUnit: { type: string; id: string; startedAt: number } | null; + elapsed: number; + completedUnits: number; + totalSlices: number; + completionRate: number; + active: boolean; + sessionCost: number; + sessionTokens: number; +} + +export interface ChangelogEntry { + milestoneId: string; + sliceId: string; + title: string; + oneLiner: string; + filesModified: { path: string; description: string }[]; + completedAt: string; +} + +export interface ChangelogInfo { + entries: ChangelogEntry[]; +} + export interface VisualizerData { milestones: VisualizerMilestone[]; phase: Phase; @@ -57,6 +89,308 @@ export interface VisualizerData { bySlice: SliceAggregate[]; byModel: ModelAggregate[]; units: UnitMetrics[]; + criticalPath: CriticalPathInfo; + remainingSliceCount: number; + agentActivity: AgentActivityInfo | null; + changelog: ChangelogInfo; +} + +// ─── Critical Path ──────────────────────────────────────────────────────────── + +export function computeCriticalPath(milestones: VisualizerMilestone[]): CriticalPathInfo { + const empty: CriticalPathInfo = { + milestonePath: [], + slicePath: [], + milestoneSlack: new Map(), + sliceSlack: new Map(), + }; + + if (milestones.length === 0) return empty; + + // Milestone-level critical path (weight = number of incomplete slices) + const msMap = new Map(milestones.map(m => [m.id, m])); + const msIds = milestones.map(m => m.id); + const msAdj = new Map(); + const msWeight = new Map(); + + for (const ms of milestones) { + msAdj.set(ms.id, []); + const incomplete = ms.slices.filter(s => !s.done).length; + msWeight.set(ms.id, ms.status === 'complete' ? 0 : Math.max(1, incomplete)); + } + + for (const ms of milestones) { + for (const dep of ms.dependsOn) { + if (msMap.has(dep)) { + const adj = msAdj.get(dep); + if (adj) adj.push(ms.id); + } + } + } + + // Topological sort (Kahn's algorithm) + const inDegree = new Map(); + for (const id of msIds) inDegree.set(id, 0); + for (const ms of milestones) { + for (const dep of ms.dependsOn) { + if (msMap.has(dep)) inDegree.set(ms.id, (inDegree.get(ms.id) ?? 0) + 1); + } + } + + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const topoOrder: string[] = []; + while (queue.length > 0) { + const node = queue.shift()!; + topoOrder.push(node); + for (const next of (msAdj.get(node) ?? [])) { + const d = (inDegree.get(next) ?? 1) - 1; + inDegree.set(next, d); + if (d === 0) queue.push(next); + } + } + + // Longest path from each root + const dist = new Map(); + const prev = new Map(); + for (const id of msIds) { + dist.set(id, 0); + prev.set(id, null); + } + + for (const node of topoOrder) { + const w = msWeight.get(node) ?? 1; + const nodeDist = dist.get(node)! + w; + for (const next of (msAdj.get(node) ?? [])) { + if (nodeDist > dist.get(next)!) { + dist.set(next, nodeDist); + prev.set(next, node); + } + } + } + + // Find the end of the critical path (node with max dist + own weight) + let maxDist = 0; + let endNode = msIds[0]; + for (const id of msIds) { + const totalDist = dist.get(id)! + (msWeight.get(id) ?? 1); + if (totalDist > maxDist) { + maxDist = totalDist; + endNode = id; + } + } + + // Trace back + const milestonePath: string[] = []; + let cur: string | null = endNode; + while (cur !== null) { + milestonePath.unshift(cur); + cur = prev.get(cur) ?? null; + } + + // Compute milestone slack + const milestoneSlack = new Map(); + const criticalSet = new Set(milestonePath); + for (const id of msIds) { + if (criticalSet.has(id)) { + milestoneSlack.set(id, 0); + } else { + const nodeTotal = dist.get(id)! + (msWeight.get(id) ?? 1); + milestoneSlack.set(id, Math.max(0, maxDist - nodeTotal)); + } + } + + // Slice-level critical path within active milestone + const activeMs = milestones.find(m => m.status === 'active'); + let slicePath: string[] = []; + const sliceSlack = new Map(); + + if (activeMs && activeMs.slices.length > 0) { + const slMap = new Map(activeMs.slices.map(s => [s.id, s])); + const slAdj = new Map(); + for (const s of activeMs.slices) slAdj.set(s.id, []); + for (const s of activeMs.slices) { + for (const dep of s.depends) { + if (slMap.has(dep)) { + const adj = slAdj.get(dep); + if (adj) adj.push(s.id); + } + } + } + + // Topo sort slices + const slIn = new Map(); + for (const s of activeMs.slices) slIn.set(s.id, 0); + for (const s of activeMs.slices) { + for (const dep of s.depends) { + if (slMap.has(dep)) slIn.set(s.id, (slIn.get(s.id) ?? 0) + 1); + } + } + + const slQueue: string[] = []; + for (const [id, d] of slIn) { + if (d === 0) slQueue.push(id); + } + + const slTopo: string[] = []; + while (slQueue.length > 0) { + const n = slQueue.shift()!; + slTopo.push(n); + for (const next of (slAdj.get(n) ?? [])) { + const d = (slIn.get(next) ?? 1) - 1; + slIn.set(next, d); + if (d === 0) slQueue.push(next); + } + } + + const slDist = new Map(); + const slPrev = new Map(); + for (const s of activeMs.slices) { + const w = s.done ? 0 : 1; + slDist.set(s.id, 0); + slPrev.set(s.id, null); + } + + for (const n of slTopo) { + const w = (slMap.get(n)?.done ? 0 : 1); + const nd = slDist.get(n)! + w; + for (const next of (slAdj.get(n) ?? [])) { + if (nd > slDist.get(next)!) { + slDist.set(next, nd); + slPrev.set(next, n); + } + } + } + + let slMax = 0; + let slEnd = activeMs.slices[0].id; + for (const s of activeMs.slices) { + const totalDist = slDist.get(s.id)! + (s.done ? 0 : 1); + if (totalDist > slMax) { + slMax = totalDist; + slEnd = s.id; + } + } + + let slCur: string | null = slEnd; + while (slCur !== null) { + slicePath.unshift(slCur); + slCur = slPrev.get(slCur) ?? null; + } + + const slCritSet = new Set(slicePath); + for (const s of activeMs.slices) { + if (slCritSet.has(s.id)) { + sliceSlack.set(s.id, 0); + } else { + const nodeTotal = slDist.get(s.id)! + (s.done ? 0 : 1); + sliceSlack.set(s.id, Math.max(0, slMax - nodeTotal)); + } + } + } + + return { milestonePath, slicePath, milestoneSlack, sliceSlack }; +} + +// ─── Agent Activity ────────────────────────────────────────────────────────── + +function loadAgentActivity(units: UnitMetrics[], milestones: VisualizerMilestone[]): AgentActivityInfo | null { + if (units.length === 0) return null; + + // Find currently running unit (finishedAt === 0) + const running = units.find(u => u.finishedAt === 0); + const now = Date.now(); + + const completedUnits = units.filter(u => u.finishedAt > 0).length; + const totalSlices = milestones.reduce((sum, m) => sum + m.slices.length, 0); + + // Completion rate from finished units + const finished = units.filter(u => u.finishedAt > 0); + let completionRate = 0; + if (finished.length >= 2) { + const earliest = Math.min(...finished.map(u => u.startedAt)); + const latest = Math.max(...finished.map(u => u.finishedAt)); + const totalHours = (latest - earliest) / 3_600_000; + completionRate = totalHours > 0 ? finished.length / totalHours : 0; + } + + const sessionCost = units.reduce((sum, u) => sum + u.cost, 0); + const sessionTokens = units.reduce((sum, u) => sum + u.tokens.total, 0); + + return { + currentUnit: running + ? { type: running.type, id: running.id, startedAt: running.startedAt } + : null, + elapsed: running ? now - running.startedAt : 0, + completedUnits, + totalSlices, + completionRate, + active: !!running, + sessionCost, + sessionTokens, + }; +} + +// ─── Changelog ─────────────────────────────────────────────────────────────── + +const changelogCache = new Map(); + +async function loadChangelog(basePath: string, milestones: VisualizerMilestone[]): Promise { + const entries: ChangelogEntry[] = []; + + for (const ms of milestones) { + for (const sl of ms.slices) { + if (!sl.done) continue; + + const summaryFile = resolveSliceFile(basePath, ms.id, sl.id, 'SUMMARY'); + if (!summaryFile) continue; + + // Check cache by file path + const cacheKey = `${ms.id}/${sl.id}`; + const cached = changelogCache.get(cacheKey); + + // Check mtime for cache invalidation + let mtime = 0; + try { + const { statSync } = await import('node:fs'); + mtime = statSync(summaryFile).mtimeMs; + } catch { + continue; + } + + if (cached && cached.mtime === mtime) { + entries.push(cached.entry); + continue; + } + + const content = await loadFile(summaryFile); + if (!content) continue; + + const summary = parseSummary(content); + const entry: ChangelogEntry = { + milestoneId: ms.id, + sliceId: sl.id, + title: sl.title, + oneLiner: summary.oneLiner, + filesModified: summary.filesModified.map(f => ({ + path: f.path, + description: f.description, + })), + completedAt: summary.frontmatter.completed_at ?? '', + }; + + changelogCache.set(cacheKey, { mtime, entry }); + entries.push(entry); + } + } + + // Sort by completedAt descending + entries.sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || '')); + + return { entries }; } // ─── Loader ─────────────────────────────────────────────────────────────────── @@ -142,6 +476,19 @@ export async function loadVisualizerData(basePath: string): Promise void }; @@ -16,7 +30,7 @@ export class GSDVisualizerOverlay { private onClose: () => void; activeTab = 0; - scrollOffsets: number[] = [0, 0, 0, 0]; + scrollOffsets: number[] = new Array(TAB_COUNT).fill(0); loading = true; disposed = false; cachedWidth?: number; @@ -25,6 +39,15 @@ export class GSDVisualizerOverlay { data: VisualizerData | null = null; basePath: string; + // Filter state (Progress tab) + filterMode = false; + filterText = ""; + filterField: "all" | "status" | "risk" | "keyword" = "all"; + + // Export state + lastExportPath?: string; + exportStatus?: string; + constructor( tui: { requestRender: () => void }, theme: Theme, @@ -52,6 +75,37 @@ export class GSDVisualizerOverlay { } handleInput(data: string): void { + // Filter mode input routing + if (this.filterMode) { + if (matchesKey(data, Key.escape)) { + this.filterMode = false; + this.filterText = ""; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.enter)) { + this.filterMode = false; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.backspace)) { + this.filterText = this.filterText.slice(0, -1); + this.invalidate(); + this.tui.requestRender(); + return; + } + // Append printable characters + if (data.length === 1 && data.charCodeAt(0) >= 32) { + this.filterText += data; + this.invalidate(); + this.tui.requestRender(); + return; + } + return; + } + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { this.dispose(); this.onClose(); @@ -59,19 +113,46 @@ export class GSDVisualizerOverlay { } if (matchesKey(data, Key.tab)) { - this.activeTab = (this.activeTab + 1) % 4; + this.activeTab = (this.activeTab + 1) % TAB_COUNT; this.invalidate(); this.tui.requestRender(); return; } - if (data === "1" || data === "2" || data === "3" || data === "4") { + if ("1234567".includes(data) && data.length === 1) { this.activeTab = parseInt(data, 10) - 1; this.invalidate(); this.tui.requestRender(); return; } + // "/" enters filter mode on Progress tab + if (data === "/" && this.activeTab === 0) { + this.filterMode = true; + this.filterText = ""; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // "f" cycles filter field on Progress tab (when not in filter mode) + if (data === "f" && this.activeTab === 0) { + const fields: Array<"all" | "status" | "risk" | "keyword"> = ["all", "status", "risk", "keyword"]; + const idx = fields.indexOf(this.filterField); + this.filterField = fields[(idx + 1) % fields.length]; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Export tab key handling + if (this.activeTab === 6 && this.data) { + if (data === "m" || data === "j" || data === "s") { + this.handleExportKey(data); + return; + } + } + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { this.scrollOffsets[this.activeTab]++; this.invalidate(); @@ -101,6 +182,62 @@ export class GSDVisualizerOverlay { } } + private handleExportKey(key: "m" | "j" | "s"): void { + if (!this.data) return; + + const format = key === "m" ? "markdown" : key === "j" ? "json" : "snapshot"; + + if (format === "snapshot") { + // Capture current active tab's rendered lines as snapshot + const snapshotLines = this.renderTabContent(this.activeTab, 80); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const { writeFileSync, mkdirSync } = require("node:fs"); + const { join } = require("node:path"); + const { gsdRoot } = require("./paths.js"); + const exportDir = gsdRoot(this.basePath); + mkdirSync(exportDir, { recursive: true }); + const outPath = join(exportDir, `snapshot-${timestamp}.txt`); + writeFileSync(outPath, snapshotLines.join("\n") + "\n", "utf-8"); + this.lastExportPath = outPath; + this.exportStatus = "Snapshot saved"; + } else { + const result = writeExportFile(this.basePath, format, this.data); + if (result) { + this.lastExportPath = result; + this.exportStatus = `${format} export saved`; + } + } + + this.invalidate(); + this.tui.requestRender(); + } + + private renderTabContent(tab: number, width: number): string[] { + if (!this.data) return []; + const th = this.theme; + switch (tab) { + case 0: { + const filter: ProgressFilter | undefined = + this.filterText ? { text: this.filterText, field: this.filterField } : undefined; + return renderProgressView(this.data, th, width, filter); + } + case 1: + return renderDepsView(this.data, th, width); + case 2: + return renderMetricsView(this.data, th, width); + case 3: + return renderTimelineView(this.data, th, width); + case 4: + return renderAgentView(this.data, th, width); + case 5: + return renderChangelogView(this.data, th, width); + case 6: + return renderExportView(this.data, th, width, this.lastExportPath); + default: + return []; + } + } + render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; @@ -112,35 +249,42 @@ export class GSDVisualizerOverlay { // Tab bar const tabs = TAB_LABELS.map((label, i) => { - if (i === this.activeTab) { - return th.fg("accent", `[${label}]`); + let displayLabel = label; + // Show filter indicator on Progress tab + if (i === 0 && this.filterText) { + displayLabel += " ✱"; } - return th.fg("dim", `[${label}]`); + if (i === this.activeTab) { + return th.fg("accent", `[${displayLabel}]`); + } + return th.fg("dim", `[${displayLabel}]`); }); - content.push(" " + tabs.join(" ")); + content.push(" " + tabs.join(" ")); content.push(""); + // Filter bar (when in filter mode) + if (this.filterMode && this.activeTab === 0) { + content.push( + th.fg("accent", `Filter (${this.filterField}): ${this.filterText}█`), + ); + content.push(""); + } + if (this.loading) { const loadingText = "Loading…"; const vis = visibleWidth(loadingText); const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2)); content.push(" ".repeat(leftPad) + loadingText); } else if (this.data) { - let viewLines: string[] = []; - switch (this.activeTab) { - case 0: - viewLines = renderProgressView(this.data, th, innerWidth); - break; - case 1: - viewLines = renderDepsView(this.data, th, innerWidth); - break; - case 2: - viewLines = renderMetricsView(this.data, th, innerWidth); - break; - case 3: - viewLines = renderTimelineView(this.data, th, innerWidth); - break; + const viewLines = this.renderTabContent(this.activeTab, innerWidth); + + // Show export status message if present + if (this.exportStatus && this.activeTab === 6) { + content.push(th.fg("success", this.exportStatus)); + content.push(""); + this.exportStatus = undefined; } + content.push(...viewLines); } @@ -156,7 +300,7 @@ export class GSDVisualizerOverlay { const lines = this.wrapInBox(visibleContent, width); // Footer hint - const hint = th.fg("dim", "Tab/1-4 switch · ↑↓ scroll · g/G top/end · esc close"); + const hint = th.fg("dim", "Tab/1-7 switch · / filter · ↑↓ scroll · g/G top/end · esc close"); const hintVis = visibleWidth(hint); const hintPad = Math.max(0, Math.floor((width - hintVis) / 2)); lines.push(" ".repeat(hintPad) + hint); diff --git a/src/resources/extensions/gsd/visualizer-views.ts b/src/resources/extensions/gsd/visualizer-views.ts index 2aca3c878..0797f9549 100644 --- a/src/resources/extensions/gsd/visualizer-views.ts +++ b/src/resources/extensions/gsd/visualizer-views.ts @@ -3,7 +3,7 @@ import type { Theme } from "@gsd/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@gsd/pi-tui"; import type { VisualizerData, VisualizerMilestone } from "./visualizer-data.js"; -import { formatCost, formatTokenCount } from "./metrics.js"; +import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js"; // ─── Local Helpers ─────────────────────────────────────────────────────────── @@ -32,16 +32,46 @@ function joinColumns(left: string, right: string, width: number): string { return left + " ".repeat(width - leftW - rightW) + right; } +function sparkline(values: number[]): string { + if (values.length === 0) return ""; + const chars = "▁▂▃▄▅▆▇█"; + const max = Math.max(...values); + if (max === 0) return chars[0].repeat(values.length); + return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join(""); +} + // ─── Progress View ─────────────────────────────────────────────────────────── +export interface ProgressFilter { + text: string; + field: "all" | "status" | "risk" | "keyword"; +} + export function renderProgressView( data: VisualizerData, th: Theme, width: number, + filter?: ProgressFilter, ): string[] { const lines: string[] = []; + // Risk Heatmap + lines.push(...renderRiskHeatmap(data, th, width)); + if (data.milestones.length > 0) lines.push(""); + + // Filter indicator + if (filter && filter.text) { + lines.push(th.fg("accent", `Filter (${filter.field}): ${filter.text}`)); + lines.push(""); + } + for (const ms of data.milestones) { + // Apply filter to milestones + if (filter && filter.text) { + const matchesMs = matchesFilter(ms, filter); + if (!matchesMs) continue; + } + // Milestone header line const statusGlyph = ms.status === "complete" @@ -70,6 +100,11 @@ export function renderProgressView( } for (const sl of ms.slices) { + // Apply filter to slices + if (filter && filter.text) { + if (!matchesSliceFilter(sl, filter)) continue; + } + // Slice line const slGlyph = sl.done ? th.fg("success", "✓") @@ -103,6 +138,78 @@ export function renderProgressView( return lines; } +function matchesFilter(ms: VisualizerMilestone, filter: ProgressFilter): boolean { + const text = filter.text.toLowerCase(); + if (filter.field === "status") { + return ms.status.includes(text); + } + if (filter.field === "risk") { + return ms.slices.some(s => s.risk.toLowerCase().includes(text)); + } + // "all" or "keyword" + if (ms.id.toLowerCase().includes(text)) return true; + if (ms.title.toLowerCase().includes(text)) return true; + if (ms.status.includes(text)) return true; + return ms.slices.some(s => matchesSliceFilter(s, filter)); +} + +function matchesSliceFilter(sl: { id: string; title: string; risk: string }, filter: ProgressFilter): boolean { + const text = filter.text.toLowerCase(); + if (filter.field === "status") return true; // slices don't have named status + if (filter.field === "risk") return sl.risk.toLowerCase().includes(text); + return sl.id.toLowerCase().includes(text) || + sl.title.toLowerCase().includes(text) || + sl.risk.toLowerCase().includes(text); +} + +// ─── Risk Heatmap ──────────────────────────────────────────────────────────── + +function renderRiskHeatmap(data: VisualizerData, th: Theme, width: number): string[] { + const allSlices = data.milestones.flatMap(m => m.slices); + if (allSlices.length === 0) return []; + + const lines: string[] = []; + lines.push(th.fg("accent", th.bold("Risk Heatmap"))); + lines.push(""); + + for (const ms of data.milestones) { + if (ms.slices.length === 0) continue; + const blocks = ms.slices.map(s => { + const color = s.risk === "high" ? "error" : s.risk === "medium" ? "warning" : "success"; + return th.fg(color, "██"); + }); + const row = ` ${padRight(ms.id, 6)} ${blocks.join(" ")}`; + lines.push(truncateToWidth(row, width)); + } + + lines.push(""); + lines.push( + ` ${th.fg("success", "██")} low ${th.fg("warning", "██")} med ${th.fg("error", "██")} high`, + ); + + // Summary counts + let low = 0, med = 0, high = 0; + let highNotStarted = 0; + for (const sl of allSlices) { + if (sl.risk === "high") { + high++; + if (!sl.done && !sl.active) highNotStarted++; + } else if (sl.risk === "medium") { + med++; + } else { + low++; + } + } + + let summary = ` Risk: ${low} low, ${med} med, ${high} high`; + if (highNotStarted > 0) { + summary += ` | ${th.fg("error", `${highNotStarted} high-risk not started`)}`; + } + lines.push(summary); + + return lines; +} + // ─── Dependencies View ─────────────────────────────────────────────────────── export function renderDepsView( @@ -153,6 +260,65 @@ export function renderDepsView( } } + lines.push(""); + + // Critical Path section + lines.push(...renderCriticalPath(data, th, width)); + + return lines; +} + +// ─── Critical Path ─────────────────────────────────────────────────────────── + +function renderCriticalPath(data: VisualizerData, th: Theme, _width: number): string[] { + const lines: string[] = []; + const cp = data.criticalPath; + + lines.push(th.fg("accent", th.bold("Critical Path"))); + lines.push(""); + + if (cp.milestonePath.length === 0) { + lines.push(th.fg("dim", " No critical path data.")); + return lines; + } + + // Milestone chain + const chain = cp.milestonePath.map(id => { + const ms = data.milestones.find(m => m.id === id); + const badge = th.fg("error", "[CRITICAL]"); + return `${id} ${badge}`; + }).join(` ${th.fg("accent", "──►")} `); + lines.push(` ${chain}`); + lines.push(""); + + // Non-critical milestones with slack + for (const ms of data.milestones) { + if (cp.milestonePath.includes(ms.id)) continue; + const slack = cp.milestoneSlack.get(ms.id) ?? 0; + lines.push(th.fg("dim", ` ${ms.id} (slack: ${slack})`)); + } + + // Slice-level critical path + if (cp.slicePath.length > 0) { + lines.push(""); + lines.push(th.fg("accent", th.bold("Slice Critical Path"))); + lines.push(""); + + const sliceChain = cp.slicePath.join(` ${th.fg("accent", "──►")} `); + lines.push(` ${sliceChain}`); + + // Bottleneck warnings + const activeMs = data.milestones.find(m => m.status === "active"); + if (activeMs) { + for (const sid of cp.slicePath) { + const sl = activeMs.slices.find(s => s.id === sid); + if (sl && !sl.done && !sl.active) { + lines.push(th.fg("warning", ` ⚠ ${sid}: critical but not yet started`)); + } + } + } + } + return lines; } @@ -232,12 +398,66 @@ export function renderMetricsView( const pctStr = `${pct.toFixed(1)}%`; lines.push(` ${label} ${bar} ${costStr} ${pctStr}`); } + + lines.push(""); + } + + // Cost Projections + lines.push(...renderCostProjections(data, th, width)); + + return lines; +} + +// ─── Cost Projections ──────────────────────────────────────────────────────── + +function renderCostProjections(data: VisualizerData, th: Theme, _width: number): string[] { + const lines: string[] = []; + + if (!data.totals || data.bySlice.length === 0) return lines; + + lines.push(th.fg("accent", th.bold("Projections"))); + lines.push(""); + + // Average cost per slice + const sliceLevelEntries = data.bySlice.filter(s => s.sliceId.includes("/")); + if (sliceLevelEntries.length < 2) { + lines.push(th.fg("dim", " Insufficient data for projections (need 2+ completed slices).")); + return lines; + } + + const totalSliceCost = sliceLevelEntries.reduce((sum, s) => sum + s.cost, 0); + const avgCostPerSlice = totalSliceCost / sliceLevelEntries.length; + const projectedRemaining = avgCostPerSlice * data.remainingSliceCount; + + lines.push(` Avg cost/slice: ${th.fg("text", formatCost(avgCostPerSlice))}`); + lines.push( + ` Projected remaining: ${th.fg("text", formatCost(projectedRemaining))} ` + + `(${formatCost(avgCostPerSlice)}/slice × ${data.remainingSliceCount} remaining)`, + ); + + // Burn rate + if (data.totals.duration > 0) { + const costPerHour = data.totals.cost / (data.totals.duration / 3_600_000); + lines.push(` Burn rate: ${th.fg("text", formatCost(costPerHour) + "/hr")}`); + } + + // Sparkline of per-slice costs + const sliceCosts = sliceLevelEntries.map(s => s.cost); + if (sliceCosts.length > 0) { + const spark = sparkline(sliceCosts); + lines.push(` Cost trend: ${spark}`); + } + + // Budget warning: projected total > 2× current spend + const projectedTotal = data.totals.cost + projectedRemaining; + if (projectedTotal > 2 * data.totals.cost && data.remainingSliceCount > 0) { + lines.push(th.fg("warning", ` ⚠ Projected total ${formatCost(projectedTotal)} exceeds 2× current spend`)); } return lines; } -// ─── Timeline View ────────────────────────────────────────────────────────── +// ─── Timeline View (Gantt) ────────────────────────────────────────────────── export function renderTimelineView( data: VisualizerData, @@ -251,6 +471,17 @@ export function renderTimelineView( return lines; } + // Gantt mode for wide terminals, list mode for narrow + if (width >= 90) { + return renderGanttView(data, th, width); + } + + return renderTimelineList(data, th, width); +} + +function renderTimelineList(data: VisualizerData, th: Theme, width: number): string[] { + const lines: string[] = []; + // Show up to 20 most recent (units are sorted by startedAt asc, show most recent) const recent = data.units.slice(-20).reverse(); @@ -291,3 +522,234 @@ export function renderTimelineView( return lines; } + +function renderGanttView(data: VisualizerData, th: Theme, width: number): string[] { + const lines: string[] = []; + const recent = data.units.slice(-20); + if (recent.length === 0) return lines; + + const finishedUnits = recent.filter(u => u.finishedAt > 0); + if (finishedUnits.length === 0) return renderTimelineList(data, th, width); + + const minStart = Math.min(...recent.map(u => u.startedAt)); + const maxEnd = Math.max(...recent.map(u => u.finishedAt > 0 ? u.finishedAt : Date.now())); + const totalSpan = maxEnd - minStart; + if (totalSpan <= 0) return renderTimelineList(data, th, width); + + const gutterWidth = 20; + const barArea = Math.max(10, width - gutterWidth - 25); + + // Time axis labels + const startLabel = formatTimeLabel(minStart); + const endLabel = formatTimeLabel(maxEnd); + lines.push( + `${" ".repeat(gutterWidth)} ${th.fg("dim", startLabel)}` + + `${" ".repeat(Math.max(1, barArea - startLabel.length - endLabel.length))}` + + `${th.fg("dim", endLabel)}`, + ); + + // Phase tracking for separators + let lastPhase = ""; + + for (const unit of recent) { + const phase = classifyUnitPhase(unit.type); + if (phase !== lastPhase && lastPhase !== "") { + lines.push(th.fg("dim", " " + "─".repeat(width - 4))); + } + lastPhase = phase; + + const end = unit.finishedAt > 0 ? unit.finishedAt : Date.now(); + const startPos = Math.round(((unit.startedAt - minStart) / totalSpan) * barArea); + const endPos = Math.round(((end - minStart) / totalSpan) * barArea); + const barLen = Math.max(1, endPos - startPos); + + const phaseColor = + phase === "research" ? "dim" : + phase === "planning" ? "accent" : + phase === "execution" ? "success" : + "warning"; + + const barStr = + " ".repeat(startPos) + + th.fg(phaseColor, "█".repeat(barLen)) + + " ".repeat(Math.max(0, barArea - startPos - barLen)); + + const gutter = padRight( + truncateToWidth(`${unit.type.slice(0, 8)} ${unit.id}`, gutterWidth - 1), + gutterWidth, + ); + + const duration = end - unit.startedAt; + const durStr = formatDuration(duration); + const costStr = formatCost(unit.cost); + + lines.push(truncateToWidth(`${gutter}${barStr} ${durStr} ${costStr}`, width)); + } + + return lines; +} + +function formatTimeLabel(ts: number): string { + const dt = new Date(ts); + return `${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`; +} + +// ─── Agent View ────────────────────────────────────────────────────────────── + +export function renderAgentView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + const activity = data.agentActivity; + + if (!activity) { + lines.push(th.fg("dim", "No agent activity data.")); + return lines; + } + + // Status line + const statusDot = activity.active + ? th.fg("success", "●") + : th.fg("dim", "○"); + const statusText = activity.active ? "ACTIVE" : "IDLE"; + const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "—"; + + lines.push( + joinColumns( + `Status: ${statusDot} ${statusText}`, + `Elapsed: ${elapsedStr}`, + width, + ), + ); + + if (activity.currentUnit) { + lines.push(`Current: ${th.fg("accent", `${activity.currentUnit.type} ${activity.currentUnit.id}`)}`); + } else { + lines.push(th.fg("dim", "Not in auto mode")); + } + + lines.push(""); + + // Progress bar + const completed = activity.completedUnits; + const total = Math.max(completed, activity.totalSlices); + if (total > 0) { + const pct = Math.min(1, completed / total); + const barW = Math.max(10, Math.min(30, width - 30)); + const fillLen = Math.round(pct * barW); + const bar = + th.fg("accent", "█".repeat(fillLen)) + + th.fg("dim", "░".repeat(barW - fillLen)); + lines.push(`Progress ${bar} ${completed}/${total} slices`); + } + + // Rate and session stats + const rateStr = activity.completionRate > 0 + ? `${activity.completionRate.toFixed(1)} units/hr` + : "—"; + lines.push( + `Rate: ${th.fg("text", rateStr)} ` + + `Session: ${th.fg("text", formatCost(activity.sessionCost))} ` + + `${th.fg("text", formatTokenCount(activity.sessionTokens))} tokens`, + ); + + lines.push(""); + + // Recent completed units (last 5) + const recentUnits = data.units.filter(u => u.finishedAt > 0).slice(-5).reverse(); + if (recentUnits.length > 0) { + lines.push(th.fg("accent", th.bold("Recent (last 5):"))); + for (const u of recentUnits) { + const dt = new Date(u.startedAt); + const hh = String(dt.getHours()).padStart(2, "0"); + const mm = String(dt.getMinutes()).padStart(2, "0"); + const dur = formatDuration(u.finishedAt - u.startedAt); + const cost = formatCost(u.cost); + const typeLabel = padRight(u.type, 16); + lines.push( + truncateToWidth( + ` ${hh}:${mm} ${th.fg("success", "✓")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`, + width, + ), + ); + } + } else { + lines.push(th.fg("dim", "No completed units yet.")); + } + + return lines; +} + +// ─── Changelog View ────────────────────────────────────────────────────────── + +export function renderChangelogView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + const changelog = data.changelog; + + if (changelog.entries.length === 0) { + lines.push(th.fg("dim", "No completed slices yet.")); + return lines; + } + + lines.push(th.fg("accent", th.bold("Changes"))); + lines.push(""); + + for (const entry of changelog.entries) { + const header = `${entry.milestoneId}/${entry.sliceId}: ${entry.title}`; + lines.push(th.fg("success", header)); + + if (entry.oneLiner) { + lines.push(` "${th.fg("text", entry.oneLiner)}"`); + } + + if (entry.filesModified.length > 0) { + lines.push(" Files:"); + for (const f of entry.filesModified) { + lines.push( + truncateToWidth( + ` ${th.fg("success", "✓")} ${f.path} — ${f.description}`, + width, + ), + ); + } + } + + if (entry.completedAt) { + lines.push(th.fg("dim", ` Completed: ${entry.completedAt}`)); + } + + lines.push(""); + } + + return lines; +} + +// ─── Export View ───────────────────────────────────────────────────────────── + +export function renderExportView( + _data: VisualizerData, + th: Theme, + _width: number, + lastExportPath?: string, +): string[] { + const lines: string[] = []; + + lines.push(th.fg("accent", th.bold("Export Options"))); + lines.push(""); + lines.push(` ${th.fg("accent", "[m]")} Markdown report — full project summary with tables`); + lines.push(` ${th.fg("accent", "[j]")} JSON report — machine-readable project data`); + lines.push(` ${th.fg("accent", "[s]")} Snapshot — current view as plain text`); + + if (lastExportPath) { + lines.push(""); + lines.push(th.fg("dim", `Last export: ${lastExportPath}`)); + } + + return lines; +}