diff --git a/src/resources/extensions/gsd/tests/visualizer-data.test.ts b/src/resources/extensions/gsd/tests/visualizer-data.test.ts index 85f740db7..8ea3788b3 100644 --- a/src/resources/extensions/gsd/tests/visualizer-data.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-data.test.ts @@ -56,6 +56,51 @@ assertTrue( "exports ChangelogInfo interface", ); +assertTrue( + dataSrc.includes("export interface SliceVerification"), + "exports SliceVerification interface", +); + +assertTrue( + dataSrc.includes("export interface KnowledgeInfo"), + "exports KnowledgeInfo interface", +); + +assertTrue( + dataSrc.includes("export interface CapturesInfo"), + "exports CapturesInfo interface", +); + +assertTrue( + dataSrc.includes("export interface HealthInfo"), + "exports HealthInfo interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerDiscussionState"), + "exports VisualizerDiscussionState interface", +); + +assertTrue( + dataSrc.includes("export type DiscussionState"), + "exports DiscussionState type", +); + +assertTrue( + dataSrc.includes("export interface VisualizerSliceRef"), + "exports VisualizerSliceRef interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerSliceActivity"), + "exports VisualizerSliceActivity interface", +); + +assertTrue( + dataSrc.includes("export interface VisualizerStats"), + "exports VisualizerStats interface", +); + // Function export assertTrue( dataSrc.includes("export async function loadVisualizerData"), @@ -123,6 +168,36 @@ assertTrue( "uses aggregateByModel", ); +assertTrue( + dataSrc.includes("aggregateByTier"), + "uses aggregateByTier", +); + +assertTrue( + dataSrc.includes("formatTierSavings"), + "uses formatTierSavings", +); + +assertTrue( + dataSrc.includes("loadAllCaptures"), + "uses loadAllCaptures", +); + +assertTrue( + dataSrc.includes("countPendingCaptures"), + "uses countPendingCaptures", +); + +assertTrue( + dataSrc.includes("loadEffectiveGSDPreferences"), + "uses loadEffectiveGSDPreferences", +); + +assertTrue( + dataSrc.includes("resolveGsdRootFile"), + "uses resolveGsdRootFile for KNOWLEDGE path", +); + // Interface fields assertTrue( dataSrc.includes("dependsOn: string[]"), @@ -144,6 +219,11 @@ assertTrue( "VisualizerData has units array", ); +assertTrue( + dataSrc.includes("estimate?: string"), + "VisualizerTask has optional estimate field", +); + // New data model fields assertTrue( dataSrc.includes("criticalPath: CriticalPathInfo"), @@ -165,6 +245,56 @@ assertTrue( "VisualizerData has changelog field", ); +assertTrue( + dataSrc.includes("sliceVerifications: SliceVerification[]"), + "VisualizerData has sliceVerifications field", +); + +assertTrue( + dataSrc.includes("knowledge: KnowledgeInfo"), + "VisualizerData has knowledge field", +); + +assertTrue( + dataSrc.includes("captures: CapturesInfo"), + "VisualizerData has captures field", +); + +assertTrue( + dataSrc.includes("health: HealthInfo"), + "VisualizerData has health field", +); + +assertTrue( + dataSrc.includes("stats: VisualizerStats"), + "VisualizerData has stats field", +); + +assertTrue( + dataSrc.includes("discussion: VisualizerDiscussionState[]"), + "VisualizerData has discussion field", +); + +assertTrue( + dataSrc.includes("loadDiscussionState"), + "uses loadDiscussionState helper", +); + +assertTrue( + dataSrc.includes("buildVisualizerStats"), + "uses buildVisualizerStats helper", +); + +assertTrue( + dataSrc.includes("byTier: TierAggregate[]"), + "VisualizerData has byTier field", +); + +assertTrue( + dataSrc.includes("tierSavingsLine: string"), + "VisualizerData has tierSavingsLine field", +); + // completedAt must be coerced to String() to handle YAML Date objects (issue #644) assertTrue( dataSrc.includes("String(summary.frontmatter.completed_at"), @@ -227,6 +357,21 @@ assertTrue( "overlay delegates to renderExportView", ); +assertTrue( + overlaySrc.includes("renderKnowledgeView"), + "overlay delegates to renderKnowledgeView", +); + +assertTrue( + overlaySrc.includes("renderCapturesView"), + "overlay delegates to renderCapturesView", +); + +assertTrue( + overlaySrc.includes("renderHealthView"), + "overlay delegates to renderHealthView", +); + assertTrue( overlaySrc.includes("handleInput"), "overlay has handleInput method", @@ -273,8 +418,8 @@ assertTrue( ); assertTrue( - overlaySrc.includes("7 Export"), - "overlay has 7 tab labels", + overlaySrc.includes("0 Health"), + "overlay has 10 tab labels", ); // Verify commands.ts integration diff --git a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts index 1c6b18657..de4ad6d82 100644 --- a/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts @@ -1,5 +1,5 @@ // Tests for GSD visualizer overlay. -// Verifies filter mode, tab switching, including reverse tab navigation, and export key handling. +// Verifies filter mode, tab switching, mouse support, page scroll, help overlay, and 10-tab config. import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; @@ -14,8 +14,8 @@ const overlaySrc = readFileSync(join(__dirname, "..", "visualizer-overlay.ts"), console.log("\n=== Overlay: Tab Configuration ==="); assertTrue( - overlaySrc.includes("TAB_COUNT = 7"), - "TAB_COUNT is 7", + overlaySrc.includes("TAB_COUNT = 10"), + "TAB_COUNT is 10", ); assertTrue( @@ -38,6 +38,21 @@ assertTrue( "has Export tab label", ); +assertTrue( + overlaySrc.includes('"8 Knowledge"'), + "has Knowledge tab label", +); + +assertTrue( + overlaySrc.includes('"9 Captures"'), + "has Captures tab label", +); + +assertTrue( + overlaySrc.includes('"0 Health"'), + "has Health tab label", +); + console.log("\n=== Overlay: Filter Mode ==="); assertTrue( @@ -69,10 +84,10 @@ assertTrue( console.log("\n=== Overlay: Tab Switching ==="); -// Supports 1-7 keys +// Supports 1-9,0 keys assertTrue( - overlaySrc.includes('"1234567"'), - "supports keys 1-7 for tab switching", + overlaySrc.includes('"1234567890"'), + "supports keys 1-9,0 for tab switching", ); // Tab wraps with TAB_COUNT @@ -86,6 +101,64 @@ assertTrue( "supports Shift+Tab for reverse tab switching", ); +console.log("\n=== Overlay: Page/Half-Page Scroll ==="); + +assertTrue( + overlaySrc.includes("Key.pageUp"), + "has Key.pageUp handler", +); + +assertTrue( + overlaySrc.includes("Key.pageDown"), + "has Key.pageDown handler", +); + +assertTrue( + overlaySrc.includes('Key.ctrl("u")'), + "has Ctrl+U half-page scroll", +); + +assertTrue( + overlaySrc.includes('Key.ctrl("d")'), + "has Ctrl+D half-page scroll", +); + +console.log("\n=== Overlay: Mouse Support ==="); + +assertTrue( + overlaySrc.includes("parseSGRMouse"), + "has parseSGRMouse method", +); + +assertTrue( + overlaySrc.includes("?1003h"), + "enables mouse tracking in constructor", +); + +assertTrue( + overlaySrc.includes("?1003l"), + "disables mouse tracking in dispose", +); + +console.log("\n=== Overlay: Collapsible Milestones ==="); + +assertTrue( + overlaySrc.includes("collapsedMilestones"), + "has collapsedMilestones state", +); + +console.log("\n=== Overlay: Help Overlay ==="); + +assertTrue( + overlaySrc.includes("showHelp"), + "has showHelp state", +); + +assertTrue( + overlaySrc.includes('data === "?"'), + "? key toggles help", +); + console.log("\n=== Overlay: Export Key Interception ==="); assertTrue( @@ -106,13 +179,18 @@ assertTrue( console.log("\n=== Overlay: Footer ==="); assertTrue( - overlaySrc.includes("Tab/Shift+Tab/1-7"), - "footer hint shows Tab, Shift+Tab, and 1-7 tab range", + overlaySrc.includes("1-9,0"), + "footer hint shows 1-9,0 tab range", ); assertTrue( - overlaySrc.includes("/ filter"), - "footer hint mentions filter", + overlaySrc.includes("PgUp/PgDn"), + "footer hint mentions PgUp/PgDn", +); + +assertTrue( + overlaySrc.includes("? help"), + "footer hint mentions ? for help", ); console.log("\n=== Overlay: Scroll Offsets ==="); diff --git a/src/resources/extensions/gsd/tests/visualizer-views.test.ts b/src/resources/extensions/gsd/tests/visualizer-views.test.ts index 9db188112..55ae77622 100644 --- a/src/resources/extensions/gsd/tests/visualizer-views.test.ts +++ b/src/resources/extensions/gsd/tests/visualizer-views.test.ts @@ -9,6 +9,9 @@ import { renderAgentView, renderChangelogView, renderExportView, + renderKnowledgeView, + renderCapturesView, + renderHealthView, } from "../visualizer-views.js"; import type { VisualizerData } from "../visualizer-data.js"; import { createTestContext } from "./test-helpers.ts"; @@ -32,6 +35,8 @@ function makeVisualizerData(overrides: Partial = {}): Visualizer byPhase: [], bySlice: [], byModel: [], + byTier: [], + tierSavingsLine: "", units: [], criticalPath: { milestonePath: [], @@ -42,6 +47,28 @@ function makeVisualizerData(overrides: Partial = {}): Visualizer remainingSliceCount: 0, agentActivity: null, changelog: { entries: [] }, + sliceVerifications: [], + knowledge: { rules: [], patterns: [], lessons: [], exists: false }, + captures: { entries: [], pendingCount: 0, totalCount: 0 }, + health: { + budgetCeiling: undefined, + tokenProfile: "standard", + truncationRate: 0, + continueHereRate: 0, + tierBreakdown: [], + tierSavingsLine: "", + toolCalls: 0, + assistantMessages: 0, + userMessages: 0, + }, + discussion: [], + stats: { + missingCount: 0, + missingSlices: [], + updatedCount: 0, + updatedSlices: [], + recentEntries: [], + }, ...overrides, }; } @@ -76,29 +103,62 @@ console.log("\n=== renderProgressView ==="); risk: "high", depends: ["S01"], tasks: [ - { id: "T01", title: "Dispatch Loop", done: false, active: true }, + { id: "T01", title: "Dispatch Loop", done: false, active: true, estimate: "30m" }, { id: "T02", title: "Session Mgmt", done: true, active: false }, ], }, - { - id: "S03", - title: "Dashboard", - done: false, - active: false, - risk: "medium", - depends: ["S02"], - tasks: [], - }, - ], - }, + { + id: "S03", + title: "Dashboard", + done: false, + active: false, + risk: "medium", + depends: ["S02"], + tasks: [], + }, + ], + }, + { + id: "M002", + title: "Plugin Arch", + status: "pending", + dependsOn: ["M001"], + slices: [], + }, + ], + sliceVerifications: [ { - id: "M002", - title: "Plugin Arch", - status: "pending", - dependsOn: ["M001"], - slices: [], + milestoneId: "M001", + sliceId: "S01", + verificationResult: "passed", + blockerDiscovered: false, + keyDecisions: [], + patternsEstablished: [], + provides: ["core-types"], + requires: [], }, ], + stats: { + missingCount: 2, + missingSlices: [ + { milestoneId: "M001", sliceId: "S02", title: "State Engine" }, + { milestoneId: "M001", sliceId: "S03", title: "Dashboard" }, + ], + updatedCount: 1, + updatedSlices: [ + { milestoneId: "M001", sliceId: "S01", title: "Core Types", completedAt: "2026-03-15T14:30:00Z" }, + ], + recentEntries: [ + { + milestoneId: "M001", + sliceId: "S01", + title: "Core Types Infrastructure", + oneLiner: "Core structures assembled", + filesModified: [], + completedAt: "2026-03-15T14:30:00Z", + }, + ], + }, }); const lines = renderProgressView(data, mockTheme, 80); @@ -108,12 +168,82 @@ console.log("\n=== renderProgressView ==="); 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"); + assertTrue(lines.some(l => l.includes("30m")), "shows task estimate"); + assertTrue(lines.some(l => l.includes("Feature Snapshot")), "shows stats header"); + assertTrue(lines.some(l => l.includes("Missing slices")), "shows missing slices count"); + assertTrue(lines.some(l => l.includes("State Engine")), "shows missing slice preview"); + assertTrue(lines.some(l => l.includes("Updated (last 7 days)")), "shows updated count"); + assertTrue(lines.some(l => l.includes("Recent completions")), "shows recent completions section"); + assertTrue(lines.some(l => l.includes("Core structures assembled")), "shows recent one-liner entry"); +} + +{ + const data = makeVisualizerData({ + discussion: [ + { + milestoneId: "M001", + title: "First Milestone", + state: "discussed", + hasContext: true, + hasDraft: false, + lastUpdated: "2026-03-15T14:30:00Z", + }, + { + milestoneId: "M002", + title: "Plugin Arch", + state: "draft", + hasContext: false, + hasDraft: true, + lastUpdated: "2026-03-16T09:00:00Z", + }, + { + milestoneId: "M003", + title: "Next Batch", + state: "undiscussed", + hasContext: false, + hasDraft: false, + lastUpdated: null, + }, + ], + }); + + const lines = renderProgressView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("Discussion Status")), "shows discussion section"); + assertTrue(lines.some(l => l.includes("Discussed: 1")), "counts discussed milestones"); + assertTrue(lines.some(l => l.includes("Draft")), "shows draft badge"); + assertTrue(lines.some(l => l.includes("Pending")), "shows pending badge"); +} + +// Verification badges +{ + const data = makeVisualizerData({ + milestones: [ + { + id: "M001", title: "Test", status: "active", dependsOn: [], + slices: [ + { id: "S01", title: "Done Slice", done: true, active: false, risk: "low", depends: [], tasks: [] }, + ], + }, + ], + sliceVerifications: [ + { + milestoneId: "M001", sliceId: "S01", + verificationResult: "passed", blockerDiscovered: true, + keyDecisions: [], patternsEstablished: [], provides: [], requires: [], + }, + ], + }); + + const lines = renderProgressView(data, mockTheme, 80); + // The verification badge should show check mark and warning + assertTrue(lines.some(l => l.includes("S01")), "shows slice with verification"); } { const data = makeVisualizerData({ milestones: [] }); const lines = renderProgressView(data, mockTheme, 80); - assertEq(lines.length, 0, "empty milestones produce no lines"); + assertTrue(lines.some(l => l.includes("Feature Snapshot")), "shows stats snapshot even when no milestones"); + assertTrue(lines.some(l => l.includes("Missing slices")), "reports missing slices count"); } // ─── Risk Heatmap ─────────────────────────────────────────────────────────── @@ -140,8 +270,6 @@ console.log("\n=== Risk Heatmap ==="); 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"); } @@ -173,12 +301,10 @@ console.log("\n=== Search/Filter ==="); ], }); - // 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"); } @@ -214,6 +340,14 @@ console.log("\n=== renderDepsView ==="); milestoneSlack: new Map([["M001", 0], ["M002", 0]]), sliceSlack: new Map([["S01", 0], ["S02", 0]]), }, + sliceVerifications: [ + { + milestoneId: "M001", sliceId: "S01", + verificationResult: "passed", blockerDiscovered: false, + keyDecisions: [], patternsEstablished: [], + provides: ["api-types"], requires: [], + }, + ], }); const lines = renderDepsView(data, mockTheme, 80); @@ -222,6 +356,8 @@ console.log("\n=== renderDepsView ==="); 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"); + assertTrue(lines.some(l => l.includes("Data Flow")), "shows data flow section"); + assertTrue(lines.some(l => l.includes("api-types")), "shows provides artifact"); } { @@ -260,13 +396,6 @@ console.log("\n=== renderMetricsView ==="); 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: [ { @@ -276,6 +405,11 @@ console.log("\n=== renderMetricsView ==="); cost: 2.50, }, ], + byTier: [ + { tier: "standard", units: 3, tokens: { input: 600, output: 300, cacheRead: 100, cacheWrite: 50, total: 1050 }, cost: 1.50, downgraded: 0 }, + { tier: "light", units: 2, tokens: { input: 400, output: 200, cacheRead: 100, cacheWrite: 50, total: 750 }, cost: 1.00, downgraded: 1 }, + ], + tierSavingsLine: "Dynamic routing: 1/5 units downgraded (20%), cost: $1.00", 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 }, @@ -288,11 +422,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"); + assertTrue(lines.some(l => l.includes("By Tier")), "shows tier breakdown section"); + assertTrue(lines.some(l => l.includes("standard")), "shows tier name"); + assertTrue(lines.some(l => l.includes("Dynamic routing")), "shows tier savings line"); + assertTrue(lines.some(l => l.includes("Tools: 15")), "shows tool call count"); + assertTrue(lines.some(l => l.includes("10") && l.includes("sent")), "shows message counts"); } { @@ -320,32 +454,16 @@ console.log("\n=== renderTimelineView ==="); 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, + tier: "standard", }, ], }); - // 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.length >= 1, "list view produces lines"); 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"); + assertTrue(listLines.some(l => l.includes("[standard]")), "shows tier in timeline"); + assertTrue(listLines.some(l => l.includes("opus-4-6")), "shows shortened model"); } { @@ -379,15 +497,21 @@ console.log("\n=== renderAgentView ==="); cost: 0.12, toolCalls: 5, assistantMessages: 3, userMessages: 1, }, ], + health: { + budgetCeiling: 10, tokenProfile: "standard", + truncationRate: 15.5, continueHereRate: 5.0, + tierBreakdown: [], tierSavingsLine: "", + toolCalls: 20, assistantMessages: 15, userMessages: 8, + }, + captures: { entries: [], pendingCount: 3, totalCount: 5 }, }); 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"); + assertTrue(lines.some(l => l.includes("Pressure")), "shows pressure section"); + assertTrue(lines.some(l => l.includes("15.5%")), "shows truncation rate"); + assertTrue(lines.some(l => l.includes("Pending captures: 3")), "shows pending captures"); } { @@ -396,25 +520,6 @@ console.log("\n=== renderAgentView ==="); 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 ==="); @@ -430,21 +535,28 @@ console.log("\n=== renderChangelogView ==="); 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", }, ], }, + sliceVerifications: [ + { + milestoneId: "M001", sliceId: "S01", + verificationResult: "passed", blockerDiscovered: false, + keyDecisions: ["Use RS256 for JWT signing"], + patternsEstablished: ["Repository pattern for data access"], + provides: [], requires: [], + }, + ], }); 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"); + assertTrue(lines.some(l => l.includes("Decisions:")), "shows decisions section"); + assertTrue(lines.some(l => l.includes("RS256")), "shows decision content"); + assertTrue(lines.some(l => l.includes("Patterns:")), "shows patterns section"); + assertTrue(lines.some(l => l.includes("Repository pattern")), "shows pattern content"); } { @@ -466,11 +578,126 @@ console.log("\n=== renderExportView ==="); assertTrue(lines.some(l => l.includes("[s]")), "shows snapshot option"); } +// ─── renderKnowledgeView ──────────────────────────────────────────────────── + +console.log("\n=== renderKnowledgeView ==="); + { - 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"); + const data = makeVisualizerData({ + knowledge: { + exists: true, + rules: [{ id: "K001", scope: "global", content: "Always use transactions" }], + patterns: [{ id: "P001", content: "Repository pattern for DB access" }], + lessons: [{ id: "L001", content: "Cache invalidation needs TTL" }], + }, + }); + + const lines = renderKnowledgeView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("Rules")), "shows rules section"); + assertTrue(lines.some(l => l.includes("K001")), "shows rule ID"); + assertTrue(lines.some(l => l.includes("Always use transactions")), "shows rule content"); + assertTrue(lines.some(l => l.includes("Patterns")), "shows patterns section"); + assertTrue(lines.some(l => l.includes("P001")), "shows pattern ID"); + assertTrue(lines.some(l => l.includes("Lessons Learned")), "shows lessons section"); + assertTrue(lines.some(l => l.includes("L001")), "shows lesson ID"); +} + +{ + const data = makeVisualizerData({ + knowledge: { exists: false, rules: [], patterns: [], lessons: [] }, + }); + const lines = renderKnowledgeView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No KNOWLEDGE.md found")), "shows no-knowledge message"); +} + +// ─── renderCapturesView ───────────────────────────────────────────────────── + +console.log("\n=== renderCapturesView ==="); + +{ + const data = makeVisualizerData({ + captures: { + entries: [ + { id: "CAP-abc123", text: "Need to add error handling", timestamp: "2026-03-15T10:00:00Z", status: "pending", classification: "inject" }, + { id: "CAP-def456", text: "Consider caching layer", timestamp: "2026-03-15T11:00:00Z", status: "triaged", classification: "defer" }, + { id: "CAP-ghi789", text: "Fixed typo in config", timestamp: "2026-03-15T12:00:00Z", status: "resolved", classification: "quick-task" }, + ], + pendingCount: 1, + totalCount: 3, + }, + }); + + const lines = renderCapturesView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("3") && l.includes("total")), "shows total count"); + assertTrue(lines.some(l => l.includes("1") && l.includes("pending")), "shows pending count"); + assertTrue(lines.some(l => l.includes("CAP-abc123")), "shows capture ID"); + assertTrue(lines.some(l => l.includes("(inject)")), "shows classification badge"); + assertTrue(lines.some(l => l.includes("[pending]")), "shows status badge"); +} + +{ + const data = makeVisualizerData({ + captures: { entries: [], pendingCount: 0, totalCount: 0 }, + }); + const lines = renderCapturesView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No captures recorded")), "shows empty state"); +} + +// ─── renderHealthView ─────────────────────────────────────────────────────── + +console.log("\n=== renderHealthView ==="); + +{ + const data = makeVisualizerData({ + totals: { + units: 10, tokens: { input: 5000, output: 2000, cacheRead: 1000, cacheWrite: 500, total: 8500 }, + cost: 5.00, duration: 120000, toolCalls: 50, + assistantMessages: 30, userMessages: 15, + totalTruncationSections: 3, continueHereFiredCount: 1, + }, + health: { + budgetCeiling: 20.00, + tokenProfile: "standard", + truncationRate: 30.0, + continueHereRate: 10.0, + tierBreakdown: [ + { tier: "standard", units: 7, tokens: { input: 3500, output: 1400, cacheRead: 700, cacheWrite: 350, total: 5950 }, cost: 3.50, downgraded: 0 }, + { tier: "light", units: 3, tokens: { input: 1500, output: 600, cacheRead: 300, cacheWrite: 150, total: 2550 }, cost: 1.50, downgraded: 2 }, + ], + tierSavingsLine: "Dynamic routing: 2/10 units downgraded (20%), cost: $1.50", + toolCalls: 50, + assistantMessages: 30, + userMessages: 15, + }, + }); + + const lines = renderHealthView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("Budget")), "shows budget section"); + assertTrue(lines.some(l => l.includes("Ceiling")), "shows budget ceiling"); + assertTrue(lines.some(l => l.includes("$20.00")), "shows ceiling amount"); + assertTrue(lines.some(l => l.includes("Pressure")), "shows pressure section"); + assertTrue(lines.some(l => l.includes("30.0%")), "shows truncation rate"); + assertTrue(lines.some(l => l.includes("Routing")), "shows routing section"); + assertTrue(lines.some(l => l.includes("standard")), "shows tier name"); + assertTrue(lines.some(l => l.includes("2 downgraded")), "shows downgraded count"); + assertTrue(lines.some(l => l.includes("Dynamic routing")), "shows savings line"); + assertTrue(lines.some(l => l.includes("Session")), "shows session section"); + assertTrue(lines.some(l => l.includes("Tool calls: 50")), "shows tool calls"); +} + +{ + const data = makeVisualizerData({ + health: { + budgetCeiling: undefined, tokenProfile: "compact", + truncationRate: 0, continueHereRate: 0, + tierBreakdown: [], tierSavingsLine: "", + toolCalls: 0, assistantMessages: 0, userMessages: 0, + }, + }); + + const lines = renderHealthView(data, mockTheme, 80); + assertTrue(lines.some(l => l.includes("No budget ceiling set")), "shows no-ceiling message"); + assertTrue(lines.some(l => l.includes("compact")), "shows token profile"); } // ─── Report ───────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/visualizer-data.ts b/src/resources/extensions/gsd/visualizer-data.ts index 3e0c75182..dcb370463 100644 --- a/src/resources/extensions/gsd/visualizer-data.ts +++ b/src/resources/extensions/gsd/visualizer-data.ts @@ -1,25 +1,32 @@ // Data loader for workflow visualizer overlay — aggregates state + metrics. +import { existsSync, readFileSync, statSync } from 'node:fs'; import { deriveState } from './state.js'; import { parseRoadmap, parsePlan, parseSummary, loadFile } from './files.js'; import { findMilestoneIds } from './guided-flow.js'; -import { resolveMilestoneFile, resolveSliceFile } from './paths.js'; +import { resolveMilestoneFile, resolveSliceFile, resolveGsdRootFile } from './paths.js'; import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, aggregateByModel, + aggregateByTier, + formatTierSavings, loadLedgerFromDisk, classifyUnitPhase, } from './metrics.js'; +import { loadAllCaptures, countPendingCaptures } from './captures.js'; +import { loadEffectiveGSDPreferences } from './preferences.js'; import type { Phase } from './types.js'; +import type { CaptureEntry } from './captures.js'; import type { ProjectTotals, PhaseAggregate, SliceAggregate, ModelAggregate, + TierAggregate, UnitMetrics, } from './metrics.js'; @@ -48,6 +55,7 @@ export interface VisualizerTask { title: string; done: boolean; active: boolean; + estimate?: string; } export interface CriticalPathInfo { @@ -81,6 +89,71 @@ export interface ChangelogInfo { entries: ChangelogEntry[]; } +export interface VisualizerSliceRef { + milestoneId: string; + sliceId: string; + title: string; +} + +export interface VisualizerSliceActivity extends VisualizerSliceRef { + completedAt: string; +} + +export interface VisualizerStats { + missingCount: number; + missingSlices: VisualizerSliceRef[]; + updatedCount: number; + updatedSlices: VisualizerSliceActivity[]; + recentEntries: ChangelogEntry[]; +} + +export type DiscussionState = 'undiscussed' | 'draft' | 'discussed'; + +export interface VisualizerDiscussionState { + milestoneId: string; + title: string; + state: DiscussionState; + hasContext: boolean; + hasDraft: boolean; + lastUpdated: string | null; +} + +export interface SliceVerification { + milestoneId: string; + sliceId: string; + verificationResult: string; + blockerDiscovered: boolean; + keyDecisions: string[]; + patternsEstablished: string[]; + provides: string[]; + requires: { slice: string; provides: string }[]; +} + +export interface KnowledgeInfo { + rules: { id: string; scope: string; content: string }[]; + patterns: { id: string; content: string }[]; + lessons: { id: string; content: string }[]; + exists: boolean; +} + +export interface CapturesInfo { + entries: CaptureEntry[]; + pendingCount: number; + totalCount: number; +} + +export interface HealthInfo { + budgetCeiling: number | undefined; + tokenProfile: string; + truncationRate: number; + continueHereRate: number; + tierBreakdown: TierAggregate[]; + tierSavingsLine: string; + toolCalls: number; + assistantMessages: number; + userMessages: number; +} + export interface VisualizerData { milestones: VisualizerMilestone[]; phase: Phase; @@ -88,11 +161,19 @@ export interface VisualizerData { byPhase: PhaseAggregate[]; bySlice: SliceAggregate[]; byModel: ModelAggregate[]; + byTier: TierAggregate[]; + tierSavingsLine: string; units: UnitMetrics[]; criticalPath: CriticalPathInfo; remainingSliceCount: number; agentActivity: AgentActivityInfo | null; changelog: ChangelogInfo; + sliceVerifications: SliceVerification[]; + knowledge: KnowledgeInfo; + captures: CapturesInfo; + health: HealthInfo; + discussion: VisualizerDiscussionState[]; + stats: VisualizerStats; } // ─── Critical Path ──────────────────────────────────────────────────────────── @@ -334,12 +415,18 @@ function loadAgentActivity(units: UnitMetrics[], milestones: VisualizerMilestone }; } -// ─── Changelog ─────────────────────────────────────────────────────────────── +// ─── Changelog & Verifications ──────────────────────────────────────────────── -const changelogCache = new Map(); +const changelogCache = new Map(); -async function loadChangelog(basePath: string, milestones: VisualizerMilestone[]): Promise { +interface ChangelogAndVerifications { + changelog: ChangelogInfo; + verifications: SliceVerification[]; +} + +async function loadChangelogAndVerifications(basePath: string, milestones: VisualizerMilestone[]): Promise { const entries: ChangelogEntry[] = []; + const verifications: SliceVerification[] = []; for (const ms of milestones) { for (const sl of ms.slices) { @@ -348,11 +435,9 @@ async function loadChangelog(basePath: string, milestones: VisualizerMilestone[] 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'); @@ -363,6 +448,7 @@ async function loadChangelog(basePath: string, milestones: VisualizerMilestone[] if (cached && cached.mtime === mtime) { entries.push(cached.entry); + verifications.push(cached.verification); continue; } @@ -382,15 +468,184 @@ async function loadChangelog(basePath: string, milestones: VisualizerMilestone[] completedAt: String(summary.frontmatter.completed_at ?? ''), }; - changelogCache.set(cacheKey, { mtime, entry }); + const verification: SliceVerification = { + milestoneId: ms.id, + sliceId: sl.id, + verificationResult: summary.frontmatter.verification_result || '', + blockerDiscovered: summary.frontmatter.blocker_discovered, + keyDecisions: summary.frontmatter.key_decisions || [], + patternsEstablished: summary.frontmatter.patterns_established || [], + provides: summary.frontmatter.provides || [], + requires: (summary.frontmatter.requires || []).map(r => ({ + slice: r.slice, + provides: r.provides, + })), + }; + + changelogCache.set(cacheKey, { mtime, entry, verification }); entries.push(entry); + verifications.push(verification); } } - // Sort by completedAt descending entries.sort((a, b) => String(b.completedAt || '').localeCompare(String(a.completedAt || ''))); - return { entries }; + return { changelog: { entries }, verifications }; +} + +// ─── Knowledge Loader ───────────────────────────────────────────────────────── + +function loadKnowledge(basePath: string): KnowledgeInfo { + const knowledgePath = resolveGsdRootFile(basePath, 'KNOWLEDGE'); + if (!existsSync(knowledgePath)) { + return { rules: [], patterns: [], lessons: [], exists: false }; + } + + let content: string; + try { + content = readFileSync(knowledgePath, 'utf-8'); + } catch { + return { rules: [], patterns: [], lessons: [], exists: false }; + } + + const rules: { id: string; scope: string; content: string }[] = []; + const patterns: { id: string; content: string }[] = []; + const lessons: { id: string; content: string }[] = []; + + const lines = content.split('\n'); + let currentSection = ''; + + for (const line of lines) { + if (line.startsWith('## Rules')) { currentSection = 'rules'; continue; } + if (line.startsWith('## Patterns')) { currentSection = 'patterns'; continue; } + if (line.startsWith('## Lessons')) { currentSection = 'lessons'; continue; } + if (line.startsWith('## ')) { currentSection = ''; continue; } + + if (!line.startsWith('| ') || line.startsWith('| ---') || line.startsWith('| ID')) continue; + const cols = line.split('|').map(c => c.trim()).filter(c => c.length > 0); + if (cols.length < 2) continue; + + if (currentSection === 'rules' && cols.length >= 3) { + rules.push({ id: cols[0], scope: cols[1], content: cols[2] }); + } else if (currentSection === 'patterns' && cols.length >= 2) { + patterns.push({ id: cols[0], content: cols[1] }); + } else if (currentSection === 'lessons' && cols.length >= 2) { + lessons.push({ id: cols[0], content: cols[1] }); + } + } + + return { rules, patterns, lessons, exists: true }; +} + +// ─── Health Loader ──────────────────────────────────────────────────────────── + +function loadHealth(units: UnitMetrics[], totals: ProjectTotals | null): HealthInfo { + const prefs = loadEffectiveGSDPreferences(); + const budgetCeiling = prefs?.preferences?.budget_ceiling; + const tokenProfile = prefs?.preferences?.token_profile ?? 'standard'; + + let truncationRate = 0; + let continueHereRate = 0; + if (totals && totals.units > 0) { + truncationRate = (totals.totalTruncationSections / totals.units) * 100; + continueHereRate = (totals.continueHereFiredCount / totals.units) * 100; + } + + const tierBreakdown = aggregateByTier(units); + const tierSavingsLine = formatTierSavings(units); + + return { + budgetCeiling, + tokenProfile, + truncationRate, + continueHereRate, + tierBreakdown, + tierSavingsLine, + toolCalls: totals?.toolCalls ?? 0, + assistantMessages: totals?.assistantMessages ?? 0, + userMessages: totals?.userMessages ?? 0, + }; +} + +const RECENT_ENTRY_LIMIT = 3; +const FEATURE_PREVIEW_LIMIT = 5; +const UPDATED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +function buildVisualizerStats( + milestones: VisualizerMilestone[], + entries: ChangelogEntry[], +): VisualizerStats { + const missing: VisualizerSliceRef[] = []; + for (const ms of milestones) { + for (const sl of ms.slices) { + if (!sl.done) missing.push({ milestoneId: ms.id, sliceId: sl.id, title: sl.title }); + } + } + + const missingCount = missing.length; + const missingSlices = missing.slice(0, FEATURE_PREVIEW_LIMIT); + + const now = Date.now(); + const updatedEntries = entries.filter(entry => { + if (!entry.completedAt) return false; + const parsed = Date.parse(entry.completedAt); + return !Number.isNaN(parsed) && now - parsed <= UPDATED_WINDOW_MS; + }); + const updatedCount = updatedEntries.length; + const updatedSlices = updatedEntries.slice(0, FEATURE_PREVIEW_LIMIT).map(entry => ({ + milestoneId: entry.milestoneId, + sliceId: entry.sliceId, + title: entry.title, + completedAt: entry.completedAt, + })); + + const recentEntries = entries.slice(0, RECENT_ENTRY_LIMIT); + + return { + missingCount, + missingSlices, + updatedCount, + updatedSlices, + recentEntries, + }; +} + +function loadDiscussionState( + basePath: string, + milestones: VisualizerMilestone[], +): VisualizerDiscussionState[] { + const states: VisualizerDiscussionState[] = []; + + for (const ms of milestones) { + const contextPath = resolveMilestoneFile(basePath, ms.id, "CONTEXT"); + const draftPath = resolveMilestoneFile(basePath, ms.id, "CONTEXT-DRAFT"); + const state: DiscussionState = contextPath + ? "discussed" + : draftPath + ? "draft" + : "undiscussed"; + + let lastUpdated: string | null = null; + const target = contextPath ?? draftPath; + if (target) { + try { + lastUpdated = new Date(statSync(target).mtimeMs).toISOString(); + } catch { + lastUpdated = null; + } + } + + states.push({ + milestoneId: ms.id, + title: ms.title, + state, + hasContext: !!contextPath, + hasDraft: !!draftPath, + lastUpdated, + }); + } + + return states; } // ─── Loader ─────────────────────────────────────────────────────────────────── @@ -433,6 +688,7 @@ export async function loadVisualizerData(basePath: string): Promise void }; private theme: Theme; @@ -39,7 +49,7 @@ export class GSDVisualizerOverlay { data: VisualizerData | null = null; basePath: string; - // Filter state (Progress tab) + // Filter state filterMode = false; filterText = ""; filterField: "all" | "status" | "risk" | "keyword" = "all"; @@ -48,6 +58,11 @@ export class GSDVisualizerOverlay { lastExportPath?: string; exportStatus?: string; + // New state + private lastVisibleRows = 20; + collapsedMilestones = new Set(); + showHelp = false; + constructor( tui: { requestRender: () => void }, theme: Theme, @@ -58,6 +73,9 @@ export class GSDVisualizerOverlay { this.onClose = onClose; this.basePath = process.cwd(); + // Enable SGR mouse tracking + process.stdout.write("\x1b[?1003h\x1b[?1006h"); + loadVisualizerData(this.basePath).then((d) => { this.data = d; this.loading = false; @@ -74,6 +92,17 @@ export class GSDVisualizerOverlay { }, 2000); } + private parseSGRMouse(data: string): { button: number; x: number; y: number; press: boolean } | null { + const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/); + if (!match) return null; + return { + button: parseInt(match[1], 10), + x: parseInt(match[2], 10), + y: parseInt(match[3], 10), + press: match[4] === "M", + }; + } + handleInput(data: string): void { // Filter mode input routing if (this.filterMode) { @@ -106,6 +135,53 @@ export class GSDVisualizerOverlay { return; } + // Help overlay dismissal + if (this.showHelp) { + if (matchesKey(data, Key.escape) || data === "?") { + this.showHelp = false; + this.invalidate(); + this.tui.requestRender(); + return; + } + return; + } + + // Mouse handling (before keyboard checks) + const mouse = this.parseSGRMouse(data); + if (mouse) { + if (mouse.button === 64) { + // Wheel up + this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - 3); + this.invalidate(); + this.tui.requestRender(); + return; + } + if (mouse.button === 65) { + // Wheel down + this.scrollOffsets[this.activeTab] += 3; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (mouse.button === 0 && mouse.press) { + // Left click — check if on tab bar row + if (mouse.y === 2) { + let xPos = 3; + for (let i = 0; i < TAB_LABELS.length; i++) { + const tabWidth = TAB_LABELS[i].length + 2; + if (mouse.x >= xPos && mouse.x < xPos + tabWidth) { + this.activeTab = i; + this.invalidate(); + this.tui.requestRender(); + return; + } + xPos += tabWidth + 1; + } + } + } + return; + } + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { this.dispose(); this.onClose(); @@ -126,15 +202,16 @@ export class GSDVisualizerOverlay { return; } - if ("1234567".includes(data) && data.length === 1) { - this.activeTab = parseInt(data, 10) - 1; + if ("1234567890".includes(data) && data.length === 1) { + const idx = data === "0" ? 9 : parseInt(data, 10) - 1; + this.activeTab = idx; this.invalidate(); this.tui.requestRender(); return; } - // "/" enters filter mode on Progress tab - if (data === "/" && this.activeTab === 0) { + // "/" enters filter mode on any tab + if (data === "/") { this.filterMode = true; this.filterText = ""; this.invalidate(); @@ -142,16 +219,48 @@ export class GSDVisualizerOverlay { 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]; + // "f" cycles filter field (limit to all/keyword on non-Progress tabs) + if (data === "f") { + if (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]; + } else { + this.filterField = this.filterField === "all" ? "keyword" : "all"; + } this.invalidate(); this.tui.requestRender(); return; } + // "?" toggles help overlay + if (data === "?") { + this.showHelp = true; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Enter/Space toggles collapse on Progress tab + if ((matchesKey(data, Key.enter) || data === " ") && this.activeTab === 0 && this.data) { + const viewLines = this.renderTabContent(0, 80); + const offset = this.scrollOffsets[0]; + for (const ms of this.data.milestones) { + const lineIdx = viewLines.findIndex(l => stripAnsi(l).includes(`${ms.id}:`)); + if (lineIdx >= offset && lineIdx < offset + this.lastVisibleRows) { + if (this.collapsedMilestones.has(ms.id)) { + this.collapsedMilestones.delete(ms.id); + } else { + this.collapsedMilestones.add(ms.id); + } + this.invalidate(); + this.tui.requestRender(); + return; + } + } + return; + } + // Export tab key handling if (this.activeTab === 6 && this.data) { if (data === "m" || data === "j" || data === "s") { @@ -160,6 +269,40 @@ export class GSDVisualizerOverlay { } } + // Page Up/Down + if (matchesKey(data, Key.pageUp)) { + const amount = Math.max(1, this.lastVisibleRows - 2); + this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - amount); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.pageDown)) { + const amount = Math.max(1, this.lastVisibleRows - 2); + this.scrollOffsets[this.activeTab] += amount; + this.invalidate(); + this.tui.requestRender(); + return; + } + + // Half-page scroll: Ctrl+U / Ctrl+D + if (matchesKey(data, Key.ctrl("u"))) { + const amount = Math.max(1, Math.floor(this.lastVisibleRows / 2)); + this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - amount); + this.invalidate(); + this.tui.requestRender(); + return; + } + + if (matchesKey(data, Key.ctrl("d"))) { + const amount = Math.max(1, Math.floor(this.lastVisibleRows / 2)); + this.scrollOffsets[this.activeTab] += amount; + this.invalidate(); + this.tui.requestRender(); + return; + } + if (matchesKey(data, Key.down) || matchesKey(data, "j")) { this.scrollOffsets[this.activeTab]++; this.invalidate(); @@ -226,7 +369,7 @@ export class GSDVisualizerOverlay { case 0: { const filter: ProgressFilter | undefined = this.filterText ? { text: this.filterText, field: this.filterField } : undefined; - return renderProgressView(this.data, th, width, filter); + return renderProgressView(this.data, th, width, filter, this.collapsedMilestones); } case 1: return renderDepsView(this.data, th, width); @@ -240,11 +383,46 @@ export class GSDVisualizerOverlay { return renderChangelogView(this.data, th, width); case 6: return renderExportView(this.data, th, width, this.lastExportPath); + case 7: + return renderKnowledgeView(this.data, th, width); + case 8: + return renderCapturesView(this.data, th, width); + case 9: + return renderHealthView(this.data, th, width); default: return []; } } + private renderHelpContent(width: number): string[] { + const th = this.theme; + const lines: string[] = []; + lines.push(th.fg("accent", th.bold("Keyboard Shortcuts"))); + lines.push(""); + const bindings: [string, string][] = [ + ["Tab/Shift+Tab", "Next/Previous tab"], + ["1-9, 0", "Jump to tab"], + ["j/k, Up/Down", "Scroll line"], + ["PgUp/PgDn", "Scroll page"], + ["Ctrl+U/Ctrl+D", "Scroll half-page"], + ["g/G", "Top/Bottom"], + ["/", "Search/filter"], + ["f", "Cycle filter field"], + ["Enter/Space", "Toggle collapse (Progress)"], + ["Mouse wheel", "Scroll"], + ["Click tab", "Switch tab"], + ["?", "Toggle help"], + ["Esc", "Close"], + ]; + for (const [key, desc] of bindings) { + const keyStr = th.fg("accent", key.padEnd(20)); + lines.push(` ${keyStr} ${desc}`); + } + lines.push(""); + lines.push(th.fg("dim", "Press ? or Esc to dismiss")); + return lines; + } + render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; @@ -257,9 +435,13 @@ export class GSDVisualizerOverlay { // Tab bar const tabs = TAB_LABELS.map((label, i) => { let displayLabel = label; - // Show filter indicator on Progress tab - if (i === 0 && this.filterText) { - displayLabel += " ✱"; + // Show filter indicator on active tab with filter + if (i === this.activeTab && this.filterText) { + displayLabel += " \u2731"; + } + // Show captures badge + if (i === 8 && this.data?.captures?.pendingCount) { + displayLabel += ` (${this.data.captures.pendingCount})`; } if (i === this.activeTab) { return th.fg("accent", `[${displayLabel}]`); @@ -269,21 +451,23 @@ export class GSDVisualizerOverlay { content.push(" " + tabs.join(" ")); content.push(""); - // Filter bar (when in filter mode) - if (this.filterMode && this.activeTab === 0) { + // Filter bar (when in filter mode on any tab) + if (this.filterMode) { content.push( - th.fg("accent", `Filter (${this.filterField}): ${this.filterText}█`), + th.fg("accent", `Filter (${this.filterField}): ${this.filterText}\u2588`), ); content.push(""); } - if (this.loading) { - const loadingText = "Loading…"; + if (this.showHelp) { + content.push(...this.renderHelpContent(innerWidth)); + } else if (this.loading) { + const loadingText = "Loading\u2026"; const vis = visibleWidth(loadingText); const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2)); content.push(" ".repeat(leftPad) + loadingText); } else if (this.data) { - const viewLines = this.renderTabContent(this.activeTab, innerWidth); + let viewLines = this.renderTabContent(this.activeTab, innerWidth); // Show export status message if present if (this.exportStatus && this.activeTab === 6) { @@ -292,6 +476,12 @@ export class GSDVisualizerOverlay { this.exportStatus = undefined; } + // Apply cross-tab filter for non-Progress tabs + if (this.filterText && this.activeTab !== 0) { + const lowerFilter = this.filterText.toLowerCase(); + viewLines = viewLines.filter(line => stripAnsi(line).toLowerCase().includes(lowerFilter)); + } + content.push(...viewLines); } @@ -299,15 +489,17 @@ export class GSDVisualizerOverlay { const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24); const chromeHeight = 2; const visibleContentRows = Math.max(1, viewportHeight - chromeHeight); + this.lastVisibleRows = visibleContentRows; + const totalLines = content.length; 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); + const lines = this.wrapInBox(visibleContent, width, offset, visibleContentRows, totalLines); // Footer hint - const hint = th.fg("dim", "Tab/Shift+Tab/1-7 switch · / filter · ↑↓ scroll · g/G top/end · esc close"); + const hint = th.fg("dim", "Tab/Shift+Tab/1-9,0 switch \u00b7 / filter \u00b7 PgUp/PgDn scroll \u00b7 ? help \u00b7 esc close"); const hintVis = visibleWidth(hint); const hintPad = Math.max(0, Math.floor((width - hintVis) / 2)); lines.push(" ".repeat(hintPad) + hint); @@ -317,18 +509,33 @@ export class GSDVisualizerOverlay { return lines; } - private wrapInBox(inner: string[], width: number): string[] { + private wrapInBox(inner: string[], width: number, offset?: number, visibleRows?: number, totalLines?: 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) { + lines.push(border("\u256d" + "\u2500".repeat(width - 2) + "\u256e")); + + // Compute scroll indicator positions + const scrollable = totalLines !== undefined && visibleRows !== undefined && totalLines > visibleRows; + let thumbStart = -1; + let thumbLen = 0; + const innerRows = inner.length; + if (scrollable && innerRows > 0 && totalLines! > 0) { + thumbStart = Math.round(((offset ?? 0) / totalLines!) * innerRows); + thumbLen = Math.max(1, Math.round((visibleRows! / totalLines!) * innerRows)); + } + + for (let i = 0; i < inner.length; i++) { + const line = inner[i]; const truncated = truncateToWidth(line, innerWidth); const padWidth = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│")); + const rightBorder = scrollable && i >= thumbStart && i < thumbStart + thumbLen + ? border("\u2503") + : border("\u2502"); + lines.push(border("\u2502") + " " + truncated + " ".repeat(padWidth) + " " + rightBorder); } - lines.push(border("╰" + "─".repeat(width - 2) + "╯")); + lines.push(border("\u2570" + "\u2500".repeat(width - 2) + "\u256f")); return lines; } @@ -340,5 +547,7 @@ export class GSDVisualizerOverlay { dispose(): void { this.disposed = true; clearInterval(this.refreshTimer); + // Disable SGR mouse tracking + process.stdout.write("\x1b[?1003l\x1b[?1006l"); } } diff --git a/src/resources/extensions/gsd/visualizer-views.ts b/src/resources/extensions/gsd/visualizer-views.ts index 0797f9549..032d74f3f 100644 --- a/src/resources/extensions/gsd/visualizer-views.ts +++ b/src/resources/extensions/gsd/visualizer-views.ts @@ -2,7 +2,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 type { VisualizerData, VisualizerMilestone, SliceVerification, VisualizerSliceActivity, VisualizerStats, VisualizerSliceRef } from "./visualizer-data.js"; import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js"; // ─── Local Helpers ─────────────────────────────────────────────────────────── @@ -34,12 +34,103 @@ function joinColumns(left: string, right: string, width: number): string { function sparkline(values: number[]): string { if (values.length === 0) return ""; - const chars = "▁▂▃▄▅▆▇█"; + const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"; 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(""); } +function formatCompletionDate(input: string): string { + if (!input) return "unknown"; + const parsed = new Date(input); + if (Number.isNaN(parsed.getTime())) return input; + return parsed.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function sliceLabel(slice: VisualizerSliceRef): string { + return `${slice.milestoneId}/${slice.sliceId}`; +} + +function renderFeatureStats(data: VisualizerData, th: Theme, width: number): string[] { + const stats = data.stats; + const lines: string[] = []; + lines.push(th.fg("accent", th.bold("Feature Snapshot"))); + lines.push(""); + + const missingLabel = `Missing slices: ${th.fg("warning", String(stats.missingCount))}`; + lines.push(truncateToWidth(` ${missingLabel}`, width)); + if (stats.missingSlices.length > 0) { + for (const slice of stats.missingSlices) { + const row = ` ${th.fg("dim", sliceLabel(slice))} ${slice.title}`; + lines.push(truncateToWidth(row, width)); + } + const remaining = stats.missingCount - stats.missingSlices.length; + if (remaining > 0) { + lines.push(truncateToWidth(` ... and ${remaining} more`, width)); + } + } + + lines.push(""); + const updatedLabel = `Updated (last 7 days): ${th.fg("accent", String(stats.updatedCount))}`; + lines.push(truncateToWidth(` ${updatedLabel}`, width)); + if (stats.updatedSlices.length > 0) { + for (const slice of stats.updatedSlices) { + const when = formatCompletionDate(slice.completedAt); + const row = ` ${th.fg("text", sliceLabel(slice))} ${th.fg("dim", when)} ${slice.title}`; + lines.push(truncateToWidth(row, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` Recent completions: ${th.fg("success", String(stats.recentEntries.length))}`, width)); + for (const entry of stats.recentEntries) { + const when = formatCompletionDate(entry.completedAt); + const row = ` ${th.fg("text", entry.sliceId)} — ${entry.oneLiner || entry.title} ${th.fg("dim", when)}`; + lines.push(truncateToWidth(row, width)); + } + + lines.push(""); + return lines; +} + +function renderDiscussionStatus(data: VisualizerData, th: Theme, width: number): string[] { + const states = data.discussion; + if (states.length === 0) return []; + + const counts = { + discussed: 0, + draft: 0, + undiscussed: 0, + }; + for (const state of states) counts[state.state]++; + + const lines: string[] = []; + lines.push(th.fg("accent", th.bold("Discussion Status"))); + lines.push(""); + const summary = ` Discussed: ${th.fg("success", String(counts.discussed))} Draft: ${th.fg("warning", String(counts.draft))} Pending: ${th.fg("dim", String(counts.undiscussed))}`; + lines.push(truncateToWidth(summary, width)); + lines.push(""); + + for (const state of states) { + const badge = + state.state === "discussed" + ? th.fg("success", "Discussed") + : state.state === "draft" + ? th.fg("warning", "Draft") + : th.fg("dim", "Pending"); + const when = state.lastUpdated ? ` ${th.fg("dim", formatCompletionDate(state.lastUpdated))}` : ""; + const row = ` ${th.fg("text", state.milestoneId)} ${badge} ${state.title}${when}`; + lines.push(truncateToWidth(row, width)); + } + + lines.push(""); + return lines; +} + +function findVerification(data: VisualizerData, milestoneId: string, sliceId: string): SliceVerification | undefined { + return data.sliceVerifications.find(v => v.milestoneId === milestoneId && v.sliceId === sliceId); +} + // ─── Progress View ─────────────────────────────────────────────────────────── export interface ProgressFilter { @@ -52,6 +143,7 @@ export function renderProgressView( th: Theme, width: number, filter?: ProgressFilter, + collapsed?: Set, ): string[] { const lines: string[] = []; @@ -65,6 +157,9 @@ export function renderProgressView( lines.push(""); } + lines.push(...renderFeatureStats(data, th, width)); + lines.push(...renderDiscussionStatus(data, th, width)); + for (const ms of data.milestones) { // Apply filter to milestones if (filter && filter.text) { @@ -75,20 +170,25 @@ export function renderProgressView( // Milestone header line const statusGlyph = ms.status === "complete" - ? th.fg("success", "✓") + ? th.fg("success", "\u2713") : ms.status === "active" - ? th.fg("accent", "▸") - : th.fg("dim", "○"); + ? th.fg("accent", "\u25b8") + : th.fg("dim", "\u25cb"); 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 collapseIndicator = collapsed?.has(ms.id) ? "[+] " : ""; + const msLeft = `${collapseIndicator}${ms.id}: ${ms.title}`; const msRight = `${statusGlyph} ${statusLabel}`; lines.push(joinColumns(msLeft, msRight, width)); + // If collapsed, skip rendering slices/tasks + if (collapsed?.has(ms.id)) continue; + if (ms.slices.length === 0 && ms.dependsOn.length > 0) { lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`)); continue; @@ -107,10 +207,10 @@ export function renderProgressView( // Slice line const slGlyph = sl.done - ? th.fg("success", "✓") + ? th.fg("success", "\u2713") : sl.active - ? th.fg("accent", "▸") - : th.fg("dim", "○"); + ? th.fg("accent", "\u25b8") + : th.fg("dim", "\u25cb"); const riskColor = sl.risk === "high" ? "warning" @@ -118,18 +218,36 @@ export function renderProgressView( ? "text" : "dim"; const riskBadge = th.fg(riskColor, sl.risk); - const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}`; + + // Verification badge + const ver = findVerification(data, ms.id, sl.id); + let verBadge = ""; + if (ver) { + if (ver.verificationResult === "passed") { + verBadge = " " + th.fg("success", "\u2713"); + } else if (ver.verificationResult === "failed") { + verBadge = " " + th.fg("error", "\u2717"); + } else if (ver.verificationResult === "untested" || ver.verificationResult === "") { + verBadge = " " + th.fg("dim", "?"); + } + if (ver.blockerDiscovered) { + verBadge += " " + th.fg("warning", "\u26a0"); + } + } + + const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}${verBadge}`; 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", "✓") + ? th.fg("success", "\u2713") : task.active - ? th.fg("accent", "▸") - : th.fg("dim", "○"); - lines.push(` ${tGlyph} ${task.id}: ${task.title}`); + ? th.fg("accent", "\u25b8") + : th.fg("dim", "\u25cb"); + const estimateStr = task.estimate ? th.fg("dim", ` (${task.estimate})`) : ""; + lines.push(` ${tGlyph} ${task.id}: ${task.title}${estimateStr}`); } } } @@ -176,7 +294,7 @@ function renderRiskHeatmap(data: VisualizerData, th: Theme, width: number): stri 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, "██"); + return th.fg(color, "\u2588\u2588"); }); const row = ` ${padRight(ms.id, 6)} ${blocks.join(" ")}`; lines.push(truncateToWidth(row, width)); @@ -184,7 +302,7 @@ function renderRiskHeatmap(data: VisualizerData, th: Theme, width: number): stri lines.push(""); lines.push( - ` ${th.fg("success", "██")} low ${th.fg("warning", "██")} med ${th.fg("error", "██")} high`, + ` ${th.fg("success", "\u2588\u2588")} low ${th.fg("warning", "\u2588\u2588")} med ${th.fg("error", "\u2588\u2588")} high`, ); // Summary counts @@ -230,7 +348,7 @@ export function renderDepsView( 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)}`, + ` ${th.fg("text", dep)} ${th.fg("accent", "\u2500\u2500\u25ba")} ${th.fg("text", ms.id)}`, ); } } @@ -253,7 +371,7 @@ export function renderDepsView( 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)}`, + ` ${th.fg("text", dep)} ${th.fg("accent", "\u2500\u2500\u25ba")} ${th.fg("text", sl.id)}`, ); } } @@ -265,6 +383,37 @@ export function renderDepsView( // Critical Path section lines.push(...renderCriticalPath(data, th, width)); + // Data Flow section from slice verifications + lines.push(""); + lines.push(...renderDataFlow(data, th)); + + return lines; +} + +// ─── Data Flow ─────────────────────────────────────────────────────────────── + +function renderDataFlow(data: VisualizerData, th: Theme): string[] { + const lines: string[] = []; + const versWithProvides = data.sliceVerifications.filter(v => v.provides.length > 0); + const versWithRequires = data.sliceVerifications.filter(v => v.requires.length > 0); + + if (versWithProvides.length === 0 && versWithRequires.length === 0) return lines; + + lines.push(th.fg("accent", th.bold("Data Flow"))); + lines.push(""); + + for (const v of versWithProvides) { + for (const artifact of v.provides) { + lines.push(` ${th.fg("text", v.sliceId)} ${th.fg("accent", "\u2500\u2500\u25ba")} ${th.fg("dim", `[${artifact}]`)}`); + } + } + + for (const v of versWithRequires) { + for (const req of v.requires) { + lines.push(` ${th.fg("dim", `[${req.provides}]`)} ${th.fg("accent", "\u25c4\u2500\u2500")} ${th.fg("text", req.slice)}`); + } + } + return lines; } @@ -284,10 +433,9 @@ function renderCriticalPath(data: VisualizerData, th: Theme, _width: number): st // 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", "──►")} `); + }).join(` ${th.fg("accent", "\u2500\u2500\u25ba")} `); lines.push(` ${chain}`); lines.push(""); @@ -304,7 +452,7 @@ function renderCriticalPath(data: VisualizerData, th: Theme, _width: number): st lines.push(th.fg("accent", th.bold("Slice Critical Path"))); lines.push(""); - const sliceChain = cp.slicePath.join(` ${th.fg("accent", "──►")} `); + const sliceChain = cp.slicePath.join(` ${th.fg("accent", "\u2500\u2500\u25ba")} `); lines.push(` ${sliceChain}`); // Bottleneck warnings @@ -313,7 +461,7 @@ function renderCriticalPath(data: VisualizerData, th: Theme, _width: number): st 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`)); + lines.push(th.fg("warning", ` \u26a0 ${sid}: critical but not yet started`)); } } } @@ -347,6 +495,10 @@ export function renderMetricsView( `Tokens: ${th.fg("text", formatTokenCount(totals.tokens.total))} ` + `Units: ${th.fg("text", String(totals.units))}`, ); + lines.push( + ` Tools: ${th.fg("text", String(totals.toolCalls))} ` + + `Messages: ${th.fg("text", String(totals.assistantMessages))} sent / ${th.fg("text", String(totals.userMessages))} received`, + ); lines.push(""); const barWidth = Math.max(10, width - 40); @@ -365,8 +517,8 @@ export function renderMetricsView( ? Math.round((phase.cost / maxPhaseCost) * barWidth) : 0; const bar = - th.fg("accent", "█".repeat(fillLen)) + - th.fg("dim", "░".repeat(barWidth - fillLen)); + th.fg("accent", "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".repeat(barWidth - fillLen)); const label = padRight(phase.phase, 14); const costStr = formatCost(phase.cost); const pctStr = `${pct.toFixed(1)}%`; @@ -391,8 +543,8 @@ export function renderMetricsView( ? Math.round((model.cost / maxModelCost) * barWidth) : 0; const bar = - th.fg("accent", "█".repeat(fillLen)) + - th.fg("dim", "░".repeat(barWidth - fillLen)); + th.fg("accent", "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".repeat(barWidth - fillLen)); const label = padRight(model.model, 20); const costStr = formatCost(model.cost); const pctStr = `${pct.toFixed(1)}%`; @@ -402,6 +554,36 @@ export function renderMetricsView( lines.push(""); } + // By Tier + if (data.byTier.length > 0) { + lines.push(th.fg("accent", th.bold("By Tier"))); + lines.push(""); + + const maxTierCost = Math.max(...data.byTier.map((t) => t.cost)); + + for (const tier of data.byTier) { + const pct = totals.cost > 0 ? (tier.cost / totals.cost) * 100 : 0; + const fillLen = + maxTierCost > 0 + ? Math.round((tier.cost / maxTierCost) * barWidth) + : 0; + const bar = + th.fg("accent", "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".repeat(barWidth - fillLen)); + const label = padRight(tier.tier, 12); + const costStr = formatCost(tier.cost); + const pctStr = `${pct.toFixed(1)}%`; + const unitsStr = `${tier.units} units`; + lines.push(` ${label} ${bar} ${costStr} ${pctStr} ${unitsStr}`); + } + + if (data.tierSavingsLine) { + lines.push(` ${th.fg("success", data.tierSavingsLine)}`); + } + + lines.push(""); + } + // Cost Projections lines.push(...renderCostProjections(data, th, width)); @@ -432,7 +614,7 @@ function renderCostProjections(data: VisualizerData, th: Theme, _width: number): 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)`, + `(${formatCost(avgCostPerSlice)}/slice \u00d7 ${data.remainingSliceCount} remaining)`, ); // Burn rate @@ -448,10 +630,10 @@ function renderCostProjections(data: VisualizerData, th: Theme, _width: number): lines.push(` Cost trend: ${spark}`); } - // Budget warning: projected total > 2× current spend + // Budget warning: projected total > 2x 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`)); + lines.push(th.fg("warning", ` \u26a0 Projected total ${formatCost(projectedTotal)} exceeds 2\u00d7 current spend`)); } return lines; @@ -479,6 +661,10 @@ export function renderTimelineView( return renderTimelineList(data, th, width); } +function shortenModel(model: string): string { + return model.replace(/^claude-/, "").slice(0, 12); +} + function renderTimelineList(data: VisualizerData, th: Theme, width: number): string[] { const lines: string[] = []; @@ -499,8 +685,8 @@ function renderTimelineList(data: VisualizerData, th: Theme, width: number): str const duration = unit.finishedAt - unit.startedAt; const glyph = unit.finishedAt > 0 - ? th.fg("success", "✓") - : th.fg("accent", "▸"); + ? th.fg("success", "\u2713") + : th.fg("accent", "\u25b8"); const typeLabel = padRight(unit.type, 16); const idLabel = padRight(unit.id, 14); @@ -510,13 +696,18 @@ function renderTimelineList(data: VisualizerData, th: Theme, width: number): str ? Math.round((duration / maxDuration) * timeBarWidth) : 0; const bar = - th.fg("accent", "█".repeat(fillLen)) + - th.fg("dim", "░".repeat(timeBarWidth - fillLen)); + th.fg("accent", "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".repeat(timeBarWidth - fillLen)); const durStr = formatDuration(duration); const costStr = formatCost(unit.cost); - const line = ` ${time} ${glyph} ${typeLabel} ${idLabel} ${bar} ${durStr} ${costStr}`; + // Tier and model info + const tierLabel = unit.tier ? th.fg("dim", `[${unit.tier}]`) : ""; + const modelLabel = th.fg("dim", shortenModel(unit.model)); + const tierModelPart = [tierLabel, modelLabel].filter(Boolean).join(" "); + + const line = ` ${time} ${glyph} ${typeLabel} ${tierModelPart} ${idLabel} ${bar} ${durStr} ${costStr}`; lines.push(truncateToWidth(line, width)); } @@ -554,7 +745,7 @@ function renderGanttView(data: VisualizerData, th: Theme, width: number): string for (const unit of recent) { const phase = classifyUnitPhase(unit.type); if (phase !== lastPhase && lastPhase !== "") { - lines.push(th.fg("dim", " " + "─".repeat(width - 4))); + lines.push(th.fg("dim", " " + "\u2500".repeat(width - 4))); } lastPhase = phase; @@ -571,11 +762,12 @@ function renderGanttView(data: VisualizerData, th: Theme, width: number): string const barStr = " ".repeat(startPos) + - th.fg(phaseColor, "█".repeat(barLen)) + + th.fg(phaseColor, "\u2588".repeat(barLen)) + " ".repeat(Math.max(0, barArea - startPos - barLen)); + const tierTag = unit.tier ? `[${unit.tier[0]}]` : ""; const gutter = padRight( - truncateToWidth(`${unit.type.slice(0, 8)} ${unit.id}`, gutterWidth - 1), + truncateToWidth(`${unit.type.slice(0, 8)} ${unit.id}${tierTag}`, gutterWidth - 1), gutterWidth, ); @@ -611,10 +803,10 @@ export function renderAgentView( // Status line const statusDot = activity.active - ? th.fg("success", "●") - : th.fg("dim", "○"); + ? th.fg("success", "\u25cf") + : th.fg("dim", "\u25cb"); const statusText = activity.active ? "ACTIVE" : "IDLE"; - const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "—"; + const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "\u2014"; lines.push( joinColumns( @@ -640,15 +832,15 @@ export function renderAgentView( 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)); + th.fg("accent", "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".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` - : "—"; + : "\u2014"; lines.push( `Rate: ${th.fg("text", rateStr)} ` + `Session: ${th.fg("text", formatCost(activity.sessionCost))} ` + @@ -657,6 +849,21 @@ export function renderAgentView( lines.push(""); + // Budget pressure + const health = data.health; + const truncColor = health.truncationRate < 10 ? "success" : health.truncationRate < 30 ? "warning" : "error"; + const contColor = health.continueHereRate < 10 ? "success" : health.continueHereRate < 30 ? "warning" : "error"; + lines.push(th.fg("accent", th.bold("Pressure"))); + lines.push(` Truncation rate: ${th.fg(truncColor, `${health.truncationRate.toFixed(1)}%`)}`); + lines.push(` Continue-here rate: ${th.fg(contColor, `${health.continueHereRate.toFixed(1)}%`)}`); + + // Pending captures + if (data.captures.pendingCount > 0) { + lines.push(` Pending captures: ${th.fg("warning", String(data.captures.pendingCount))}`); + } + + lines.push(""); + // Recent completed units (last 5) const recentUnits = data.units.filter(u => u.finishedAt > 0).slice(-5).reverse(); if (recentUnits.length > 0) { @@ -670,7 +877,7 @@ export function renderAgentView( const typeLabel = padRight(u.type, 16); lines.push( truncateToWidth( - ` ${hh}:${mm} ${th.fg("success", "✓")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`, + ` ${hh}:${mm} ${th.fg("success", "\u2713")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`, width, ), ); @@ -713,13 +920,30 @@ export function renderChangelogView( for (const f of entry.filesModified) { lines.push( truncateToWidth( - ` ${th.fg("success", "✓")} ${f.path} — ${f.description}`, + ` ${th.fg("success", "\u2713")} ${f.path} \u2014 ${f.description}`, width, ), ); } } + // Decisions and patterns from slice verification + const ver = findVerification(data, entry.milestoneId, entry.sliceId); + if (ver) { + if (ver.keyDecisions.length > 0) { + lines.push(" Decisions:"); + for (const d of ver.keyDecisions) { + lines.push(` - ${d}`); + } + } + if (ver.patternsEstablished.length > 0) { + lines.push(" Patterns:"); + for (const p of ver.patternsEstablished) { + lines.push(` - ${p}`); + } + } + } + if (entry.completedAt) { lines.push(th.fg("dim", ` Completed: ${entry.completedAt}`)); } @@ -742,9 +966,9 @@ export function renderExportView( 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`); + lines.push(` ${th.fg("accent", "[m]")} Markdown report \u2014 full project summary with tables`); + lines.push(` ${th.fg("accent", "[j]")} JSON report \u2014 machine-readable project data`); + lines.push(` ${th.fg("accent", "[s]")} Snapshot \u2014 current view as plain text`); if (lastExportPath) { lines.push(""); @@ -753,3 +977,193 @@ export function renderExportView( return lines; } + +// ─── Knowledge View ────────────────────────────────────────────────────────── + +export function renderKnowledgeView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + const knowledge = data.knowledge; + + if (!knowledge.exists) { + lines.push(th.fg("dim", "No KNOWLEDGE.md found")); + return lines; + } + + if (knowledge.rules.length === 0 && knowledge.patterns.length === 0 && knowledge.lessons.length === 0) { + lines.push(th.fg("dim", "KNOWLEDGE.md exists but is empty")); + return lines; + } + + // Rules section + if (knowledge.rules.length > 0) { + lines.push(th.fg("accent", th.bold("Rules"))); + lines.push(""); + for (const rule of knowledge.rules) { + lines.push(truncateToWidth( + ` ${th.fg("accent", rule.id)} ${th.fg("dim", `[${rule.scope}]`)} ${rule.content}`, + width, + )); + } + lines.push(""); + } + + // Patterns section + if (knowledge.patterns.length > 0) { + lines.push(th.fg("accent", th.bold("Patterns"))); + lines.push(""); + for (const pattern of knowledge.patterns) { + lines.push(truncateToWidth( + ` ${th.fg("accent", pattern.id)} ${pattern.content}`, + width, + )); + } + lines.push(""); + } + + // Lessons section + if (knowledge.lessons.length > 0) { + lines.push(th.fg("accent", th.bold("Lessons Learned"))); + lines.push(""); + for (const lesson of knowledge.lessons) { + lines.push(truncateToWidth( + ` ${th.fg("accent", lesson.id)} ${lesson.content}`, + width, + )); + } + lines.push(""); + } + + return lines; +} + +// ─── Captures View ─────────────────────────────────────────────────────────── + +export function renderCapturesView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + const captures = data.captures; + + // Summary line + const resolved = captures.entries.filter(e => e.status === "resolved").length; + lines.push( + `${th.fg("text", String(captures.totalCount))} total \u00b7 ` + + `${th.fg("warning", String(captures.pendingCount))} pending \u00b7 ` + + `${th.fg("dim", String(resolved))} resolved`, + ); + lines.push(""); + + if (captures.entries.length === 0) { + lines.push(th.fg("dim", "No captures recorded.")); + return lines; + } + + // Group by status: pending first, then triaged, then resolved + const statusOrder: Record = { pending: 0, triaged: 1, resolved: 2 }; + const sorted = [...captures.entries].sort((a, b) => + (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3), + ); + + for (const entry of sorted) { + const statusColor = + entry.status === "pending" ? "warning" : + entry.status === "triaged" ? "accent" : + "dim"; + + const classColor = + entry.classification === "inject" ? "warning" : + entry.classification === "quick-task" ? "accent" : + entry.classification === "replan" ? "error" : + entry.classification === "defer" ? "text" : + "dim"; + + const classBadge = entry.classification + ? th.fg(classColor, `(${entry.classification})`) + : ""; + + const statusBadge = th.fg(statusColor, `[${entry.status}]`); + const textPreview = truncateToWidth(entry.text, Math.max(20, width - 50)); + + lines.push(` ${th.fg("accent", entry.id)} ${statusBadge} ${textPreview} ${classBadge}`); + if (entry.timestamp) { + lines.push(` ${th.fg("dim", entry.timestamp)}`); + } + } + + return lines; +} + +// ─── Health View ───────────────────────────────────────────────────────────── + +export function renderHealthView( + data: VisualizerData, + th: Theme, + width: number, +): string[] { + const lines: string[] = []; + const health = data.health; + + // Budget section + lines.push(th.fg("accent", th.bold("Budget"))); + lines.push(""); + if (health.budgetCeiling !== undefined) { + const currentSpend = data.totals?.cost ?? 0; + const pct = health.budgetCeiling > 0 ? Math.min(1, currentSpend / health.budgetCeiling) : 0; + const barW = Math.max(10, Math.min(30, width - 40)); + const fillLen = Math.round(pct * barW); + const budgetColor = pct < 0.7 ? "success" : pct < 0.9 ? "warning" : "error"; + const bar = + th.fg(budgetColor, "\u2588".repeat(fillLen)) + + th.fg("dim", "\u2591".repeat(barW - fillLen)); + lines.push(` Ceiling: ${th.fg("text", formatCost(health.budgetCeiling))}`); + lines.push(` Spend: ${bar} ${formatCost(currentSpend)} (${(pct * 100).toFixed(1)}%)`); + } else { + lines.push(th.fg("dim", " No budget ceiling set")); + } + lines.push(` Token profile: ${th.fg("text", health.tokenProfile)}`); + lines.push(""); + + // Pressure section + lines.push(th.fg("accent", th.bold("Pressure"))); + lines.push(""); + const truncColor = health.truncationRate < 10 ? "success" : health.truncationRate < 30 ? "warning" : "error"; + const contColor = health.continueHereRate < 10 ? "success" : health.continueHereRate < 30 ? "warning" : "error"; + const pressBarW = Math.max(10, Math.min(20, width - 50)); + + const truncFill = Math.round((Math.min(health.truncationRate, 100) / 100) * pressBarW); + const truncBar = th.fg(truncColor, "\u2588".repeat(truncFill)) + th.fg("dim", "\u2591".repeat(pressBarW - truncFill)); + lines.push(` Truncation: ${truncBar} ${health.truncationRate.toFixed(1)}%`); + + const contFill = Math.round((Math.min(health.continueHereRate, 100) / 100) * pressBarW); + const contBar = th.fg(contColor, "\u2588".repeat(contFill)) + th.fg("dim", "\u2591".repeat(pressBarW - contFill)); + lines.push(` Continue-here: ${contBar} ${health.continueHereRate.toFixed(1)}%`); + lines.push(""); + + // Routing section + if (health.tierBreakdown.length > 0) { + lines.push(th.fg("accent", th.bold("Routing"))); + lines.push(""); + for (const tier of health.tierBreakdown) { + const downTag = tier.downgraded > 0 ? th.fg("warning", ` (${tier.downgraded} downgraded)`) : ""; + lines.push(` ${padRight(tier.tier, 12)} ${tier.units} units ${formatCost(tier.cost)}${downTag}`); + } + if (health.tierSavingsLine) { + lines.push(` ${th.fg("success", health.tierSavingsLine)}`); + } + lines.push(""); + } + + // Session section + lines.push(th.fg("accent", th.bold("Session"))); + lines.push(""); + lines.push(` Tool calls: ${th.fg("text", String(health.toolCalls))}`); + lines.push(` Messages: ${th.fg("text", String(health.assistantMessages))} sent / ${th.fg("text", String(health.userMessages))} received`); + + return lines; +}