From 5ade4bf3ede4ab6c056b0cba84289c0b1589741c Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Mon, 16 Mar 2026 09:19:08 -0500 Subject: [PATCH] feat: add workflow visualizer TUI overlay with 4-tab interactive view (#626) 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 --- src/resources/extensions/gsd/auto.ts | 5 + src/resources/extensions/gsd/commands.ts | 34 +- src/resources/extensions/gsd/preferences.ts | 2 + .../gsd/tests/visualizer-data.test.ts | 198 ++++++++++++ .../gsd/tests/visualizer-views.test.ts | 255 +++++++++++++++ .../extensions/gsd/visualizer-data.ts | 154 +++++++++ .../extensions/gsd/visualizer-overlay.ts | 193 ++++++++++++ .../extensions/gsd/visualizer-views.ts | 293 ++++++++++++++++++ 8 files changed, 1131 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/visualizer-data.test.ts create mode 100644 src/resources/extensions/gsd/tests/visualizer-views.test.ts create mode 100644 src/resources/extensions/gsd/visualizer-data.ts create mode 100644 src/resources/extensions/gsd/visualizer-overlay.ts create mode 100644 src/resources/extensions/gsd/visualizer-views.ts diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 1964a215c..afa824d95 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1433,6 +1433,11 @@ async function dispatchNextUnit( "info", ); sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone"); + // Hint: visualizer available after milestone transition + const vizPrefs = loadEffectiveGSDPreferences()?.preferences; + if (vizPrefs?.auto_visualize) { + ctx.ui.notify("Run /gsd visualize to see progress overview.", "info"); + } // Reset stuck detection for new milestone unitDispatchCount.clear(); unitRecoveryCount.clear(); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index ad01c7b65..34b08ce28 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -11,6 +11,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; +import { GSDVisualizerOverlay } from "./visualizer-overlay.js"; import { showQueue, showDiscuss } from "./guided-flow.js"; import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js"; import { resolveProjectRoot } from "./worktree.js"; @@ -65,10 +66,10 @@ function projectRoot(): string { export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge", + description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge", getArgumentCompletions: (prefix: string) => { const subcommands = [ - "next", "auto", "stop", "pause", "status", "queue", "discuss", + "next", "auto", "stop", "pause", "status", "visualize", "queue", "discuss", "capture", "triage", "history", "undo", "skip", "export", "cleanup", "prefs", "config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge", @@ -165,6 +166,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "visualize") { + await handleVisualize(ctx); + return; + } + if (trimmed === "prefs" || trimmed.startsWith("prefs ")) { await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx); return; @@ -318,7 +324,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|capture|triage|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer |knowledge .`, + `Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|discuss|history|undo|skip |export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer |knowledge .`, "warning", ); }, @@ -356,6 +362,28 @@ export async function fireStatusViaCommand( await handleStatus(ctx as ExtensionCommandContext); } +async function handleVisualize(ctx: ExtensionCommandContext): Promise { + if (!ctx.hasUI) { + ctx.ui.notify("Visualizer requires an interactive terminal.", "warning"); + return; + } + + await ctx.ui.custom( + (tui, theme, _kb, done) => { + return new GSDVisualizerOverlay(tui, theme, () => done()); + }, + { + overlay: true, + overlayOptions: { + width: "80%", + minWidth: 80, + maxHeight: "90%", + anchor: "center", + }, + }, + ); +} + async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise { const trimmed = args.trim(); diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 04fc534a5..0fabd71f5 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -41,6 +41,7 @@ const KNOWN_PREFERENCE_KEYS = new Set([ "dynamic_routing", "token_profile", "phases", + "auto_visualize", ]); export interface GSDSkillRule { @@ -134,6 +135,7 @@ export interface GSDPreferences { dynamic_routing?: DynamicRoutingConfig; token_profile?: TokenProfile; phases?: PhaseSkipPreferences; + auto_visualize?: boolean; } export interface LoadedGSDPreferences { diff --git a/src/resources/extensions/gsd/tests/visualizer-data.test.ts b/src/resources/extensions/gsd/tests/visualizer-data.test.ts new file mode 100644 index 000000000..3545630d6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/visualizer-data.test.ts @@ -0,0 +1,198 @@ +// Tests for GSD visualizer data loader. +// Verifies the VisualizerData interface shape and source-file contracts. + +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, report } = createTestContext(); + +const dataPath = join(__dirname, "..", "visualizer-data.ts"); +const dataSrc = readFileSync(dataPath, "utf-8"); + +console.log("\n=== visualizer-data.ts source contracts ==="); + +// Interface exports +assertTrue( + dataSrc.includes("export interface VisualizerData"), + "exports VisualizerData interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerMilestone"), + "exports VisualizerMilestone interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerSlice"), + "exports VisualizerSlice interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerTask"), + "exports VisualizerTask interface", +); + +// Function export +assertTrue( + dataSrc.includes("export async function loadVisualizerData"), + "exports loadVisualizerData function", +); + +// Data source usage +assertTrue( + dataSrc.includes("deriveState"), + "uses deriveState for state derivation", +); + +assertTrue( + dataSrc.includes("findMilestoneIds"), + "uses findMilestoneIds to enumerate milestones", +); + +assertTrue( + dataSrc.includes("parseRoadmap"), + "uses parseRoadmap for roadmap parsing", +); + +assertTrue( + dataSrc.includes("parsePlan"), + "uses parsePlan for plan parsing", +); + +assertTrue( + dataSrc.includes("getLedger"), + "uses getLedger for in-memory metrics", +); + +assertTrue( + dataSrc.includes("loadLedgerFromDisk"), + "uses loadLedgerFromDisk as fallback", +); + +assertTrue( + dataSrc.includes("getProjectTotals"), + "uses getProjectTotals for aggregation", +); + +assertTrue( + dataSrc.includes("aggregateByPhase"), + "uses aggregateByPhase", +); + +assertTrue( + dataSrc.includes("aggregateBySlice"), + "uses aggregateBySlice", +); + +assertTrue( + dataSrc.includes("aggregateByModel"), + "uses aggregateByModel", +); + +// Interface fields +assertTrue( + dataSrc.includes("dependsOn: string[]"), + "VisualizerMilestone has dependsOn field", +); + +assertTrue( + dataSrc.includes("depends: string[]"), + "VisualizerSlice has depends field", +); + +assertTrue( + dataSrc.includes("totals: ProjectTotals | null"), + "VisualizerData has nullable totals", +); + +assertTrue( + dataSrc.includes("units: UnitMetrics[]"), + "VisualizerData has units array", +); + +// Verify overlay source exists and imports data module +const overlayPath = join(__dirname, "..", "visualizer-overlay.ts"); +const overlaySrc = readFileSync(overlayPath, "utf-8"); + +console.log("\n=== visualizer-overlay.ts source contracts ==="); + +assertTrue( + overlaySrc.includes("export class GSDVisualizerOverlay"), + "exports GSDVisualizerOverlay class", +); + +assertTrue( + overlaySrc.includes("loadVisualizerData"), + "overlay uses loadVisualizerData", +); + +assertTrue( + overlaySrc.includes("renderProgressView"), + "overlay delegates to renderProgressView", +); + +assertTrue( + overlaySrc.includes("renderDepsView"), + "overlay delegates to renderDepsView", +); + +assertTrue( + overlaySrc.includes("renderMetricsView"), + "overlay delegates to renderMetricsView", +); + +assertTrue( + overlaySrc.includes("renderTimelineView"), + "overlay delegates to renderTimelineView", +); + +assertTrue( + overlaySrc.includes("handleInput"), + "overlay has handleInput method", +); + +assertTrue( + overlaySrc.includes("dispose"), + "overlay has dispose method", +); + +assertTrue( + overlaySrc.includes("wrapInBox"), + "overlay has wrapInBox helper", +); + +assertTrue( + overlaySrc.includes("activeTab"), + "overlay tracks active tab", +); + +assertTrue( + overlaySrc.includes("scrollOffsets"), + "overlay tracks per-tab scroll offsets", +); + +// Verify commands.ts integration +const commandsPath = join(__dirname, "..", "commands.ts"); +const commandsSrc = readFileSync(commandsPath, "utf-8"); + +console.log("\n=== commands.ts integration ==="); + +assertTrue( + commandsSrc.includes('"visualize"'), + "commands.ts has visualize in subcommands array", +); + +assertTrue( + commandsSrc.includes("GSDVisualizerOverlay"), + "commands.ts imports GSDVisualizerOverlay", +); + +assertTrue( + commandsSrc.includes("handleVisualize"), + "commands.ts has handleVisualize handler", +); + +report(); diff --git a/src/resources/extensions/gsd/tests/visualizer-views.test.ts b/src/resources/extensions/gsd/tests/visualizer-views.test.ts new file mode 100644 index 000000000..8bf5cb78d --- /dev/null +++ b/src/resources/extensions/gsd/tests/visualizer-views.test.ts @@ -0,0 +1,255 @@ +// Tests for GSD visualizer view renderers. +// Tests the pure view functions with mock data — no file I/O. + +import { + renderProgressView, + renderDepsView, + renderMetricsView, + renderTimelineView, +} from "../visualizer-views.js"; +import type { VisualizerData } from "../visualizer-data.js"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +// ─── Mock theme ───────────────────────────────────────────────────────────── + +const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +} as any; + +// ─── Test data factories ──────────────────────────────────────────────────── + +function makeVisualizerData(overrides: Partial = {}): VisualizerData { + return { + milestones: [], + phase: "executing", + totals: null, + byPhase: [], + bySlice: [], + byModel: [], + units: [], + ...overrides, + }; +} + +// ─── renderProgressView ───────────────────────────────────────────────────── + +console.log("\n=== renderProgressView ==="); + +{ + const data = makeVisualizerData({ + milestones: [ + { + id: "M001", + title: "First Milestone", + status: "active", + dependsOn: [], + slices: [ + { + id: "S01", + title: "Core Types", + done: true, + active: false, + risk: "low", + depends: [], + tasks: [], + }, + { + id: "S02", + title: "State Engine", + done: false, + active: true, + risk: "high", + depends: ["S01"], + tasks: [ + { id: "T01", title: "Dispatch Loop", done: false, active: true }, + { id: "T02", title: "Session Mgmt", done: true, active: false }, + ], + }, + { + id: "S03", + title: "Dashboard", + done: false, + active: false, + risk: "medium", + depends: ["S02"], + tasks: [], + }, + ], + }, + { + id: "M002", + title: "Plugin Arch", + status: "pending", + dependsOn: ["M001"], + slices: [], + }, + ], + }); + + const lines = renderProgressView(data, mockTheme, 80); + assertTrue(lines.length > 0, "progress view produces output"); + assertTrue(lines.some(l => l.includes("M001")), "shows milestone M001"); + assertTrue(lines.some(l => l.includes("S01")), "shows slice S01"); + assertTrue(lines.some(l => l.includes("T01")), "shows task T01 for active slice"); + assertTrue(lines.some(l => l.includes("M002")), "shows milestone M002"); + assertTrue(lines.some(l => l.includes("depends on M001")), "shows dependency note"); +} + +{ + const data = makeVisualizerData({ milestones: [] }); + const lines = renderProgressView(data, mockTheme, 80); + assertEq(lines.length, 0, "empty milestones produce no lines"); +} + +// ─── renderDepsView ───────────────────────────────────────────────────────── + +console.log("\n=== renderDepsView ==="); + +{ + const data = makeVisualizerData({ + milestones: [ + { + id: "M001", + title: "First", + status: "active", + dependsOn: [], + slices: [ + { id: "S01", title: "A", done: false, active: true, risk: "low", depends: [], tasks: [] }, + { id: "S02", title: "B", done: false, active: false, risk: "low", depends: ["S01"], tasks: [] }, + ], + }, + { + id: "M002", + title: "Second", + status: "pending", + dependsOn: ["M001"], + slices: [], + }, + ], + }); + + 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"); +} + +{ + const data = makeVisualizerData({ + milestones: [ + { id: "M001", title: "Only", status: "active", dependsOn: [], slices: [] }, + ], + }); + + const lines = renderDepsView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No milestone dependencies")), "shows no-deps message"); +} + +// ─── renderMetricsView ────────────────────────────────────────────────────── + +console.log("\n=== renderMetricsView ==="); + +{ + const data = makeVisualizerData({ + totals: { + units: 5, + tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 }, + cost: 2.50, + duration: 60000, + toolCalls: 15, + assistantMessages: 10, + userMessages: 5, + }, + byPhase: [ + { + phase: "execution", + units: 3, + tokens: { input: 600, output: 300, cacheRead: 100, cacheWrite: 50, total: 1050 }, + cost: 1.50, + duration: 40000, + }, + { + phase: "planning", + units: 2, + tokens: { input: 400, output: 200, cacheRead: 100, cacheWrite: 50, total: 750 }, + cost: 1.00, + duration: 20000, + }, + ], + byModel: [ + { + model: "claude-opus-4-6", + units: 5, + tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 }, + cost: 2.50, + }, + ], + }); + + const lines = renderMetricsView(data, mockTheme, 80); + assertTrue(lines.length > 0, "metrics view produces output"); + 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"); +} + +{ + const data = makeVisualizerData({ totals: null }); + const lines = renderMetricsView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No metrics data")), "shows no-data message"); +} + +// ─── renderTimelineView ───────────────────────────────────────────────────── + +console.log("\n=== renderTimelineView ==="); + +{ + const now = Date.now(); + const data = makeVisualizerData({ + units: [ + { + type: "execute-task", + id: "M001/S01/T01", + model: "claude-opus-4-6", + startedAt: now - 120000, + finishedAt: now - 60000, + tokens: { input: 500, output: 200, cacheRead: 100, cacheWrite: 50, total: 850 }, + cost: 0.42, + toolCalls: 5, + assistantMessages: 3, + userMessages: 1, + }, + { + type: "plan-slice", + id: "M001/S02", + model: "claude-opus-4-6", + startedAt: now - 60000, + finishedAt: now - 30000, + tokens: { input: 300, output: 150, cacheRead: 50, cacheWrite: 25, total: 525 }, + cost: 0.18, + toolCalls: 2, + assistantMessages: 2, + userMessages: 1, + }, + ], + }); + + 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"); +} + +{ + const data = makeVisualizerData({ units: [] }); + const lines = renderTimelineView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No execution history")), "shows empty message"); +} + +// ─── Report ───────────────────────────────────────────────────────────────── + +report(); diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts new file mode 100644 index 000000000..74936789d --- /dev/null +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -0,0 +1,154 @@ +// Data loader for workflow visualizer overlay — aggregates state + metrics. + +import { deriveState } from './state.js'; +import { parseRoadmap, parsePlan, loadFile } from './files.js'; +import { findMilestoneIds } from './guided-flow.js'; +import { resolveMilestoneFile, resolveSliceFile } from './paths.js'; +import { + getLedger, + getProjectTotals, + aggregateByPhase, + aggregateBySlice, + aggregateByModel, + loadLedgerFromDisk, +} from './metrics.js'; + +import type { Phase } from './types.js'; +import type { + ProjectTotals, + PhaseAggregate, + SliceAggregate, + ModelAggregate, + UnitMetrics, +} from './metrics.js'; + +// ─── Visualizer Types ───────────────────────────────────────────────────────── + +export interface VisualizerMilestone { + id: string; + title: string; + status: 'complete' | 'active' | 'pending'; + dependsOn: string[]; + slices: VisualizerSlice[]; +} + +export interface VisualizerSlice { + id: string; + title: string; + done: boolean; + active: boolean; + risk: string; + depends: string[]; + tasks: VisualizerTask[]; +} + +export interface VisualizerTask { + id: string; + title: string; + done: boolean; + active: boolean; +} + +export interface VisualizerData { + milestones: VisualizerMilestone[]; + phase: Phase; + totals: ProjectTotals | null; + byPhase: PhaseAggregate[]; + bySlice: SliceAggregate[]; + byModel: ModelAggregate[]; + units: UnitMetrics[]; +} + +// ─── Loader ─────────────────────────────────────────────────────────────────── + +export async function loadVisualizerData(basePath: string): Promise { + const state = await deriveState(basePath); + const milestoneIds = findMilestoneIds(basePath); + + const milestones: VisualizerMilestone[] = []; + + for (const mid of milestoneIds) { + const entry = state.registry.find(r => r.id === mid); + const status = entry?.status ?? 'pending'; + const dependsOn = entry?.dependsOn ?? []; + + const slices: VisualizerSlice[] = []; + + const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP'); + const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; + + if (roadmapContent) { + const roadmap = parseRoadmap(roadmapContent); + + for (const s of roadmap.slices) { + const isActiveSlice = + state.activeMilestone?.id === mid && + state.activeSlice?.id === s.id; + + const tasks: VisualizerTask[] = []; + + if (isActiveSlice) { + const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN'); + const planContent = planFile ? await loadFile(planFile) : null; + + if (planContent) { + const plan = parsePlan(planContent); + for (const t of plan.tasks) { + tasks.push({ + id: t.id, + title: t.title, + done: t.done, + active: state.activeTask?.id === t.id, + }); + } + } + } + + slices.push({ + id: s.id, + title: s.title, + done: s.done, + active: isActiveSlice, + risk: s.risk, + depends: s.depends, + tasks, + }); + } + } + + milestones.push({ + id: mid, + title: entry?.title ?? mid, + status, + dependsOn, + slices, + }); + } + + // Metrics + let totals: ProjectTotals | null = null; + let byPhase: PhaseAggregate[] = []; + let bySlice: SliceAggregate[] = []; + let byModel: ModelAggregate[] = []; + let units: UnitMetrics[] = []; + + const ledger = getLedger() ?? loadLedgerFromDisk(basePath); + + if (ledger && ledger.units.length > 0) { + units = [...ledger.units].sort((a, b) => a.startedAt - b.startedAt); + totals = getProjectTotals(units); + byPhase = aggregateByPhase(units); + bySlice = aggregateBySlice(units); + byModel = aggregateByModel(units); + } + + return { + milestones, + phase: state.phase, + totals, + byPhase, + bySlice, + byModel, + units, + }; +} diff --git a/src/resources/extensions/gsd/visualizer-overlay.ts b/src/resources/extensions/gsd/visualizer-overlay.ts new file mode 100644 index 000000000..8aeb63c8e --- /dev/null +++ b/src/resources/extensions/gsd/visualizer-overlay.ts @@ -0,0 +1,193 @@ +import type { Theme } from "@gsd/pi-coding-agent"; +import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui"; +import { loadVisualizerData, type VisualizerData } from "./visualizer-data.js"; +import { + renderProgressView, + renderDepsView, + renderMetricsView, + renderTimelineView, +} from "./visualizer-views.js"; + +const TAB_LABELS = ["1 Progress", "2 Deps", "3 Metrics", "4 Timeline"]; + +export class GSDVisualizerOverlay { + private tui: { requestRender: () => void }; + private theme: Theme; + private onClose: () => void; + + activeTab = 0; + scrollOffsets: number[] = [0, 0, 0, 0]; + loading = true; + disposed = false; + cachedWidth?: number; + cachedLines?: string[]; + refreshTimer: ReturnType; + data: VisualizerData | null = null; + basePath: string; + + constructor( + tui: { requestRender: () => void }, + theme: Theme, + onClose: () => void, + ) { + this.tui = tui; + this.theme = theme; + this.onClose = onClose; + this.basePath = process.cwd(); + + loadVisualizerData(this.basePath).then((d) => { + this.data = d; + this.loading = false; + this.tui.requestRender(); + }); + + this.refreshTimer = setInterval(() => { + loadVisualizerData(this.basePath).then((d) => { + if (this.disposed) return; + this.data = d; + this.invalidate(); + this.tui.requestRender(); + }); + }, 2000); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.dispose(); + this.onClose(); + return; + } + + if (matchesKey(data, Key.tab)) { + this.activeTab = (this.activeTab + 1) % 4; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "1" || data === "2" || data === "3" || data === "4") { + this.activeTab = parseInt(data, 10) - 1; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { + this.scrollOffsets[this.activeTab]++; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.up) || matchesKey(data, "k")) { + this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - 1); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "g") { + this.scrollOffsets[this.activeTab] = 0; + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (data === "G") { + this.scrollOffsets[this.activeTab] = 999; + this.invalidate(); + this.tui.requestRender(); + return; + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const th = this.theme; + const innerWidth = width - 4; + const content: string[] = []; + + // Tab bar + const tabs = TAB_LABELS.map((label, i) => { + if (i === this.activeTab) { + return th.fg("accent", `[${label}]`); + } + return th.fg("dim", `[${label}]`); + }); + content.push(" " + tabs.join(" ")); + 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; + } + content.push(...viewLines); + } + + // Apply scroll + const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24); + const chromeHeight = 2; + const visibleContentRows = Math.max(1, viewportHeight - chromeHeight); + const maxScroll = Math.max(0, content.length - visibleContentRows); + this.scrollOffsets[this.activeTab] = Math.min(this.scrollOffsets[this.activeTab], maxScroll); + const offset = this.scrollOffsets[this.activeTab]; + const visibleContent = content.slice(offset, offset + visibleContentRows); + + 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 hintVis = visibleWidth(hint); + const hintPad = Math.max(0, Math.floor((width - hintVis) / 2)); + lines.push(" ".repeat(hintPad) + hint); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + private wrapInBox(inner: string[], width: number): string[] { + const th = this.theme; + const border = (s: string) => th.fg("borderAccent", s); + const innerWidth = width - 4; + const lines: string[] = []; + lines.push(border("╭" + "─".repeat(width - 2) + "╮")); + for (const line of inner) { + const truncated = truncateToWidth(line, innerWidth); + const padWidth = Math.max(0, innerWidth - visibleWidth(truncated)); + lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│")); + } + lines.push(border("╰" + "─".repeat(width - 2) + "╯")); + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + dispose(): void { + this.disposed = true; + clearInterval(this.refreshTimer); + } +} diff --git a/src/resources/extensions/gsd/visualizer-views.ts b/src/resources/extensions/gsd/visualizer-views.ts new file mode 100644 index 000000000..2aca3c878 --- /dev/null +++ b/src/resources/extensions/gsd/visualizer-views.ts @@ -0,0 +1,293 @@ +// View renderers for the GSD workflow visualizer overlay. + +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"; + +// ─── Local Helpers ─────────────────────────────────────────────────────────── + +function formatDuration(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} + +function padRight(content: string, width: number): string { + const vis = visibleWidth(content); + return content + " ".repeat(Math.max(0, width - vis)); +} + +function joinColumns(left: string, right: string, width: number): string { + const leftW = visibleWidth(left); + const rightW = visibleWidth(right); + if (leftW + rightW + 2 > width) { + return truncateToWidth(`${left} ${right}`, width); + } + return left + " ".repeat(width - leftW - rightW) + right; +} + +// ─── Progress View ─────────────────────────────────────────────────────────── + +export function renderProgressView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + + for (const ms of data.milestones) { + // Milestone header line + const statusGlyph = + ms.status === "complete" + ? th.fg("success", "✓") + : ms.status === "active" + ? th.fg("accent", "▸") + : th.fg("dim", "○"); + const statusLabel = + ms.status === "complete" + ? th.fg("success", "complete") + : ms.status === "active" + ? th.fg("accent", "active") + : th.fg("dim", "pending"); + const msLeft = `${ms.id}: ${ms.title}`; + const msRight = `${statusGlyph} ${statusLabel}`; + lines.push(joinColumns(msLeft, msRight, width)); + + if (ms.slices.length === 0 && ms.dependsOn.length > 0) { + lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`)); + continue; + } + + if (ms.status === "pending" && ms.dependsOn.length > 0) { + lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`)); + continue; + } + + for (const sl of ms.slices) { + // Slice line + const slGlyph = sl.done + ? th.fg("success", "✓") + : sl.active + ? th.fg("accent", "▸") + : th.fg("dim", "○"); + const riskColor = + sl.risk === "high" + ? "warning" + : sl.risk === "medium" + ? "text" + : "dim"; + const riskBadge = th.fg(riskColor, sl.risk); + const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}`; + lines.push(joinColumns(slLeft, riskBadge, width)); + + // Show tasks for active slice + if (sl.active && sl.tasks.length > 0) { + for (const task of sl.tasks) { + const tGlyph = task.done + ? th.fg("success", "✓") + : task.active + ? th.fg("accent", "▸") + : th.fg("dim", "○"); + lines.push(` ${tGlyph} ${task.id}: ${task.title}`); + } + } + } + } + + return lines; +} + +// ─── Dependencies View ─────────────────────────────────────────────────────── + +export function renderDepsView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + + // Milestone Dependencies + lines.push(th.fg("accent", th.bold("Milestone Dependencies"))); + lines.push(""); + + const msDeps = data.milestones.filter((ms) => ms.dependsOn.length > 0); + if (msDeps.length === 0) { + lines.push(th.fg("dim", " No milestone dependencies.")); + } else { + for (const ms of msDeps) { + for (const dep of ms.dependsOn) { + lines.push( + ` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", ms.id)}`, + ); + } + } + } + + lines.push(""); + + // Slice Dependencies (active milestone) + lines.push(th.fg("accent", th.bold("Slice Dependencies (active milestone)"))); + lines.push(""); + + const activeMs = data.milestones.find((ms) => ms.status === "active"); + if (!activeMs) { + lines.push(th.fg("dim", " No active milestone.")); + } else { + const slDeps = activeMs.slices.filter((sl) => sl.depends.length > 0); + if (slDeps.length === 0) { + lines.push(th.fg("dim", " No slice dependencies.")); + } else { + for (const sl of slDeps) { + for (const dep of sl.depends) { + lines.push( + ` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", sl.id)}`, + ); + } + } + } + } + + return lines; +} + +// ─── Metrics View ──────────────────────────────────────────────────────────── + +export function renderMetricsView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + + if (data.totals === null) { + lines.push(th.fg("dim", "No metrics data available.")); + return lines; + } + + const totals = data.totals; + + // Summary line + lines.push( + th.fg("accent", th.bold("Summary")), + ); + lines.push( + ` Cost: ${th.fg("text", formatCost(totals.cost))} ` + + `Tokens: ${th.fg("text", formatTokenCount(totals.tokens.total))} ` + + `Units: ${th.fg("text", String(totals.units))}`, + ); + lines.push(""); + + const barWidth = Math.max(10, width - 40); + + // By Phase + if (data.byPhase.length > 0) { + lines.push(th.fg("accent", th.bold("By Phase"))); + lines.push(""); + + const maxPhaseCost = Math.max(...data.byPhase.map((p) => p.cost)); + + for (const phase of data.byPhase) { + const pct = totals.cost > 0 ? (phase.cost / totals.cost) * 100 : 0; + const fillLen = + maxPhaseCost > 0 + ? Math.round((phase.cost / maxPhaseCost) * barWidth) + : 0; + const bar = + th.fg("accent", "█".repeat(fillLen)) + + th.fg("dim", "░".repeat(barWidth - fillLen)); + const label = padRight(phase.phase, 14); + const costStr = formatCost(phase.cost); + const pctStr = `${pct.toFixed(1)}%`; + const tokenStr = formatTokenCount(phase.tokens.total); + lines.push(` ${label} ${bar} ${costStr} ${pctStr} ${tokenStr}`); + } + + lines.push(""); + } + + // By Model + if (data.byModel.length > 0) { + lines.push(th.fg("accent", th.bold("By Model"))); + lines.push(""); + + const maxModelCost = Math.max(...data.byModel.map((m) => m.cost)); + + for (const model of data.byModel) { + const pct = totals.cost > 0 ? (model.cost / totals.cost) * 100 : 0; + const fillLen = + maxModelCost > 0 + ? Math.round((model.cost / maxModelCost) * barWidth) + : 0; + const bar = + th.fg("accent", "█".repeat(fillLen)) + + th.fg("dim", "░".repeat(barWidth - fillLen)); + const label = padRight(model.model, 20); + const costStr = formatCost(model.cost); + const pctStr = `${pct.toFixed(1)}%`; + lines.push(` ${label} ${bar} ${costStr} ${pctStr}`); + } + } + + return lines; +} + +// ─── Timeline View ────────────────────────────────────────────────────────── + +export function renderTimelineView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + + if (data.units.length === 0) { + lines.push(th.fg("dim", "No execution history.")); + return lines; + } + + // Show up to 20 most recent (units are sorted by startedAt asc, show most recent) + const recent = data.units.slice(-20).reverse(); + + const maxDuration = Math.max( + ...recent.map((u) => u.finishedAt - u.startedAt), + ); + const timeBarWidth = Math.max(4, Math.min(12, width - 60)); + + for (const unit of recent) { + const dt = new Date(unit.startedAt); + const hh = String(dt.getHours()).padStart(2, "0"); + const mm = String(dt.getMinutes()).padStart(2, "0"); + const time = `${hh}:${mm}`; + + const duration = unit.finishedAt - unit.startedAt; + const glyph = + unit.finishedAt > 0 + ? th.fg("success", "✓") + : th.fg("accent", "▸"); + + const typeLabel = padRight(unit.type, 16); + const idLabel = padRight(unit.id, 14); + + const fillLen = + maxDuration > 0 + ? Math.round((duration / maxDuration) * timeBarWidth) + : 0; + const bar = + th.fg("accent", "█".repeat(fillLen)) + + th.fg("dim", "░".repeat(timeBarWidth - fillLen)); + + const durStr = formatDuration(duration); + const costStr = formatCost(unit.cost); + + const line = ` ${time} ${glyph} ${typeLabel} ${idLabel} ${bar} ${durStr} ${costStr}`; + lines.push(truncateToWidth(line, width)); + } + + return lines; +}