feat: enrich visualizer with stats and discussion status (#791)

This commit is contained in:
Jeremy McSpadden 2026-03-16 23:14:06 -05:00 committed by GitHub
parent 59134fa426
commit 2b3846fab9
6 changed files with 1539 additions and 185 deletions

View file

@ -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

View file

@ -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 ===");

View file

@ -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<VisualizerData> = {}): Visualizer
byPhase: [],
bySlice: [],
byModel: [],
byTier: [],
tierSavingsLine: "",
units: [],
criticalPath: {
milestonePath: [],
@ -42,6 +47,28 @@ function makeVisualizerData(overrides: Partial<VisualizerData> = {}): 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 ─────────────────────────────────────────────────────────────────

View file

@ -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<string, { mtime: number; entry: ChangelogEntry }>();
const changelogCache = new Map<string, { mtime: number; entry: ChangelogEntry; verification: SliceVerification }>();
async function loadChangelog(basePath: string, milestones: VisualizerMilestone[]): Promise<ChangelogInfo> {
interface ChangelogAndVerifications {
changelog: ChangelogInfo;
verifications: SliceVerification[];
}
async function loadChangelogAndVerifications(basePath: string, milestones: VisualizerMilestone[]): Promise<ChangelogAndVerifications> {
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<VisualizerDa
title: t.title,
done: t.done,
active: state.activeTask?.id === t.id,
estimate: t.estimate || undefined,
});
}
}
@ -464,6 +720,8 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
let byPhase: PhaseAggregate[] = [];
let bySlice: SliceAggregate[] = [];
let byModel: ModelAggregate[] = [];
let byTier: TierAggregate[] = [];
let tierSavingsLine = '';
let units: UnitMetrics[] = [];
const ledger = getLedger() ?? loadLedgerFromDisk(basePath);
@ -474,6 +732,8 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
byPhase = aggregateByPhase(units);
bySlice = aggregateBySlice(units);
byModel = aggregateByModel(units);
byTier = aggregateByTier(units);
tierSavingsLine = formatTierSavings(units);
}
// Compute new fields
@ -487,7 +747,20 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
}
const agentActivity = loadAgentActivity(units, milestones);
const changelog = await loadChangelog(basePath, milestones);
const { changelog, verifications: sliceVerifications } = await loadChangelogAndVerifications(basePath, milestones);
const knowledge = loadKnowledge(basePath);
const allCaptures = loadAllCaptures(basePath);
const pendingCount = countPendingCaptures(basePath);
const captures: CapturesInfo = {
entries: allCaptures,
pendingCount,
totalCount: allCaptures.length,
};
const health = loadHealth(units, totals);
const stats = buildVisualizerStats(milestones, changelog.entries);
const discussion = loadDiscussionState(basePath, milestones);
return {
milestones,
@ -496,10 +769,18 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
byPhase,
bySlice,
byModel,
byTier,
tierSavingsLine,
units,
criticalPath,
remainingSliceCount,
agentActivity,
changelog,
sliceVerifications,
knowledge,
captures,
health,
discussion,
stats,
};
}

View file

@ -9,11 +9,14 @@ import {
renderAgentView,
renderChangelogView,
renderExportView,
renderKnowledgeView,
renderCapturesView,
renderHealthView,
type ProgressFilter,
} from "./visualizer-views.js";
import { writeExportFile } from "./export.js";
const TAB_COUNT = 7;
const TAB_COUNT = 10;
const TAB_LABELS = [
"1 Progress",
"2 Deps",
@ -22,8 +25,15 @@ const TAB_LABELS = [
"5 Agent",
"6 Changes",
"7 Export",
"8 Knowledge",
"9 Captures",
"0 Health",
];
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
export class GSDVisualizerOverlay {
private tui: { requestRender: () => 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<string>();
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");
}
}

View file

@ -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>,
): 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<string, number> = { 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;
}