feat: expand workflow visualizer with 8 new features (7-tab overlay) (#636)

* feat: add workflow visualizer TUI overlay with 4-tab interactive view

Add `/gsd visualize` command that opens a full-screen TUI overlay with
four tabs: Progress (milestone/slice/task tree), Dependencies (ASCII
dep graph), Metrics (cost/token bar charts), and Timeline (chronological
execution history). Supports Tab/1-4 switching, per-tab scrolling, and
auto-refresh every 2s. Opt-in auto-trigger hint after milestone
completion via `auto_visualize` preference.

New files:
- visualizer-data.ts: async data loader aggregating state + metrics
- visualizer-views.ts: 4 pure view renderers
- visualizer-overlay.ts: overlay class with tab/scroll/cache management
- tests/visualizer-views.test.ts: 21 assertions on view renderers
- tests/visualizer-data.test.ts: 33 source contract assertions

Modified:
- commands.ts: register "visualize" subcommand + handler
- auto.ts: milestone completion hint when auto_visualize enabled
- preferences.ts: add auto_visualize preference key

* feat: expand workflow visualizer with 8 new features across 7 tabs

Add critical path analysis, risk heatmap, cost projections, Gantt
timeline, live agent activity, diff/changelog, search/filter, and
export capabilities to the workflow visualizer overlay.

- Critical path: O(V+E) topological sort + longest path algorithm
  with slack computation for milestones and slices
- Risk heatmap: colored block grid with legend and summary counts
- Cost projections: avg cost/slice, burn rate, sparkline, budget warnings
- Gantt timeline: horizontal bars with phase coloring and time axis
  (falls back to list view on narrow terminals)
- Agent activity: real-time status, progress bar, completion rate
- Changelog: parsed SUMMARY files with mtime-based caching
- Search/filter: / enters filter mode, f cycles field, supports
  keyword/status/risk filtering
- Export: standalone writeExportFile() + m/j/s keys for
  markdown/JSON/snapshot export from overlay

Tab bar expanded from 4 to 7 tabs. 146 new test assertions across
4 test files. All 604 tests pass with zero regressions.

* fix: update help text to reflect 7-tab visualizer
This commit is contained in:
Jeremy McSpadden 2026-03-16 10:23:39 -05:00 committed by GitHub
parent 75e82a4236
commit ee14135d6c
9 changed files with 1648 additions and 33 deletions

View file

@ -369,7 +369,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
"",
"VISIBILITY",
" /gsd status Show progress dashboard (Ctrl+Alt+G)",
" /gsd visualize Interactive tree visualizer with 4-tab TUI",
" /gsd visualize Interactive 7-tab TUI (progress, deps, metrics, timeline, agent, changes, export)",
" /gsd queue Show queued/dispatched units and execution order",
" /gsd history View execution history [--cost] [--phase] [--model] [N]",
"",

View file

@ -7,12 +7,92 @@ import { writeFileSync, mkdirSync } from "node:fs";
import { join, basename } from "node:path";
import {
getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice,
aggregateByModel, formatCost, formatTokenCount,
aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk,
} from "./metrics.js";
import type { UnitMetrics } from "./metrics.js";
import { gsdRoot } from "./paths.js";
import { formatDuration } from "./history.js";
/**
* Write an export file directly, without requiring an ExtensionCommandContext.
* Used by the visualizer overlay export tab.
* Returns the output file path, or null on failure.
*/
export function writeExportFile(
basePath: string,
format: "markdown" | "json",
visualizerData?: { totals: any; byPhase: any[]; bySlice: any[]; byModel: any[]; units: any[]; criticalPath?: any; remainingSliceCount?: number },
): string | null {
const ledger = getLedger();
let units: UnitMetrics[];
if (visualizerData && visualizerData.units.length > 0) {
units = visualizerData.units;
} else if (ledger && ledger.units.length > 0) {
units = ledger.units;
} else {
const diskLedger = loadLedgerFromDisk(basePath);
if (!diskLedger || diskLedger.units.length === 0) return null;
units = diskLedger.units;
}
const projectName = basename(basePath);
const exportDir = gsdRoot(basePath);
mkdirSync(exportDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
if (format === "json") {
const report = {
exportedAt: new Date().toISOString(),
project: projectName,
totals: visualizerData?.totals ?? getProjectTotals(units),
byPhase: visualizerData?.byPhase ?? aggregateByPhase(units),
bySlice: visualizerData?.bySlice ?? aggregateBySlice(units),
byModel: visualizerData?.byModel ?? aggregateByModel(units),
units,
};
const outPath = join(exportDir, `export-${timestamp}.json`);
writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf-8");
return outPath;
} else {
const totals = visualizerData?.totals ?? getProjectTotals(units);
const phases = visualizerData?.byPhase ?? aggregateByPhase(units);
const slices = visualizerData?.bySlice ?? aggregateBySlice(units);
const md = [
`# GSD Session Report — ${projectName}`,
``,
`**Generated**: ${new Date().toISOString()}`,
`**Units completed**: ${totals.units}`,
`**Total cost**: ${formatCost(totals.cost)}`,
`**Total tokens**: ${formatTokenCount(totals.tokens.total)}`,
`**Total duration**: ${formatDuration(totals.duration)}`,
`**Tool calls**: ${totals.toolCalls}`,
``,
`## Cost by Phase`,
``,
`| Phase | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...phases.map((p: any) =>
`| ${p.phase} | ${p.units} | ${formatCost(p.cost)} | ${formatTokenCount(p.tokens.total)} | ${formatDuration(p.duration)} |`,
),
``,
`## Cost by Slice`,
``,
`| Slice | Units | Cost | Tokens | Duration |`,
`|-------|-------|------|--------|----------|`,
...slices.map((s: any) =>
`| ${s.sliceId} | ${s.units} | ${formatCost(s.cost)} | ${formatTokenCount(s.tokens.total)} | ${formatDuration(s.duration)} |`,
),
``,
].join("\n");
const outPath = join(exportDir, `export-${timestamp}.md`);
writeFileSync(outPath, md, "utf-8");
return outPath;
}
}
/**
* Export session/milestone data to JSON or markdown.
*/

View file

@ -0,0 +1,145 @@
// Tests for critical path algorithm.
// Tests computeCriticalPath with known DAG structures.
import { computeCriticalPath } from "../visualizer-data.js";
import type { VisualizerMilestone } from "../visualizer-data.js";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function makeMs(id: string, status: "complete" | "active" | "pending", dependsOn: string[], slices: any[] = []): VisualizerMilestone {
return { id, title: id, status, dependsOn, slices };
}
function makeSlice(id: string, done: boolean, depends: string[] = []) {
return { id, title: id, done, active: false, risk: "low", depends, tasks: [] };
}
// ─── Linear chain ───────────────────────────────────────────────────────────
console.log("\n=== Critical Path: Linear Chain ===");
{
// M001 -> M002 -> M003
const milestones = [
makeMs("M001", "complete", []),
makeMs("M002", "active", ["M001"], [
makeSlice("S01", true),
makeSlice("S02", false, ["S01"]),
]),
makeMs("M003", "pending", ["M002"]),
];
const cp = computeCriticalPath(milestones);
assertTrue(cp.milestonePath.length > 0, "linear chain has critical path");
assertTrue(cp.milestonePath.includes("M002"), "M002 is on critical path");
assertTrue(cp.milestonePath.includes("M003"), "M003 is on critical path");
assertEq(cp.milestoneSlack.get("M002"), 0, "M002 has zero slack");
assertEq(cp.milestoneSlack.get("M003"), 0, "M003 has zero slack");
}
// ─── Diamond DAG ────────────────────────────────────────────────────────────
console.log("\n=== Critical Path: Diamond DAG ===");
{
// M001 -> M002 -> M004
// M001 -> M003 -> M004
// M002 has 3 incomplete slices, M003 has 1 incomplete slice
const milestones = [
makeMs("M001", "complete", []),
makeMs("M002", "active", ["M001"], [
makeSlice("S01", false),
makeSlice("S02", false),
makeSlice("S03", false),
]),
makeMs("M003", "pending", ["M001"], [
makeSlice("S01", false),
]),
makeMs("M004", "pending", ["M002", "M003"]),
];
const cp = computeCriticalPath(milestones);
assertTrue(cp.milestonePath.length >= 2, "diamond DAG has critical path");
// M002 has weight 3 (3 incomplete), M003 has weight 1
// Critical path should go through M002 (longer)
assertTrue(cp.milestonePath.includes("M002"), "M002 (heavier) is on critical path");
// M003 should have non-zero slack since it's lighter
const m003Slack = cp.milestoneSlack.get("M003") ?? -1;
assertTrue(m003Slack > 0, "M003 has positive slack (lighter branch)");
}
// ─── Independent branches ───────────────────────────────────────────────────
console.log("\n=== Critical Path: Independent Branches ===");
{
// M001 (no deps), M002 (no deps), M003 (no deps)
const milestones = [
makeMs("M001", "active", [], [makeSlice("S01", false)]),
makeMs("M002", "pending", [], [makeSlice("S01", false), makeSlice("S02", false)]),
makeMs("M003", "pending", [], [makeSlice("S01", false)]),
];
const cp = computeCriticalPath(milestones);
assertTrue(cp.milestonePath.length >= 1, "independent branches have at least one critical node");
// M002 has the most incomplete slices, should be critical
assertTrue(cp.milestonePath.includes("M002"), "M002 (longest) is on critical path");
}
// ─── Slice-level critical path ──────────────────────────────────────────────
console.log("\n=== Critical Path: Slice-level ===");
{
// Active milestone with slice dependencies: S01 -> S02 -> S04, S01 -> S03
const milestones = [
makeMs("M001", "active", [], [
makeSlice("S01", true),
makeSlice("S02", false, ["S01"]),
makeSlice("S03", false, ["S01"]),
makeSlice("S04", false, ["S02"]),
]),
];
const cp = computeCriticalPath(milestones);
assertTrue(cp.slicePath.length > 0, "has slice-level critical path");
assertTrue(cp.slicePath.includes("S02"), "S02 is on slice critical path");
assertTrue(cp.slicePath.includes("S04"), "S04 is on slice critical path");
// S03 should have non-zero slack (it's a shorter branch)
const s03Slack = cp.sliceSlack.get("S03") ?? -1;
assertTrue(s03Slack > 0, "S03 has positive slack (shorter branch)");
}
// ─── Empty milestones ───────────────────────────────────────────────────────
console.log("\n=== Critical Path: Empty ===");
{
const cp = computeCriticalPath([]);
assertEq(cp.milestonePath.length, 0, "empty milestones produce empty path");
assertEq(cp.slicePath.length, 0, "empty milestones produce empty slice path");
}
// ─── Single milestone ───────────────────────────────────────────────────────
console.log("\n=== Critical Path: Single Milestone ===");
{
const milestones = [
makeMs("M001", "active", [], [
makeSlice("S01", false),
makeSlice("S02", false),
]),
];
const cp = computeCriticalPath(milestones);
assertTrue(cp.milestonePath.length === 1, "single milestone is its own critical path");
assertEq(cp.milestonePath[0], "M001", "M001 is the critical node");
}
// ─── Report ─────────────────────────────────────────────────────────────────
report();

View file

@ -35,12 +35,38 @@ assertTrue(
"exports VisualizerTask interface",
);
// New interfaces
assertTrue(
dataSrc.includes("export interface CriticalPathInfo"),
"exports CriticalPathInfo interface",
);
assertTrue(
dataSrc.includes("export interface AgentActivityInfo"),
"exports AgentActivityInfo interface",
);
assertTrue(
dataSrc.includes("export interface ChangelogEntry"),
"exports ChangelogEntry interface",
);
assertTrue(
dataSrc.includes("export interface ChangelogInfo"),
"exports ChangelogInfo interface",
);
// Function export
assertTrue(
dataSrc.includes("export async function loadVisualizerData"),
"exports loadVisualizerData function",
);
assertTrue(
dataSrc.includes("export function computeCriticalPath"),
"exports computeCriticalPath function",
);
// Data source usage
assertTrue(
dataSrc.includes("deriveState"),
@ -62,6 +88,11 @@ assertTrue(
"uses parsePlan for plan parsing",
);
assertTrue(
dataSrc.includes("parseSummary"),
"uses parseSummary for changelog parsing",
);
assertTrue(
dataSrc.includes("getLedger"),
"uses getLedger for in-memory metrics",
@ -113,6 +144,27 @@ assertTrue(
"VisualizerData has units array",
);
// New data model fields
assertTrue(
dataSrc.includes("criticalPath: CriticalPathInfo"),
"VisualizerData has criticalPath field",
);
assertTrue(
dataSrc.includes("remainingSliceCount: number"),
"VisualizerData has remainingSliceCount field",
);
assertTrue(
dataSrc.includes("agentActivity: AgentActivityInfo | null"),
"VisualizerData has agentActivity field",
);
assertTrue(
dataSrc.includes("changelog: ChangelogInfo"),
"VisualizerData has changelog field",
);
// Verify overlay source exists and imports data module
const overlayPath = join(__dirname, "..", "visualizer-overlay.ts");
const overlaySrc = readFileSync(overlayPath, "utf-8");
@ -149,6 +201,21 @@ assertTrue(
"overlay delegates to renderTimelineView",
);
assertTrue(
overlaySrc.includes("renderAgentView"),
"overlay delegates to renderAgentView",
);
assertTrue(
overlaySrc.includes("renderChangelogView"),
"overlay delegates to renderChangelogView",
);
assertTrue(
overlaySrc.includes("renderExportView"),
"overlay delegates to renderExportView",
);
assertTrue(
overlaySrc.includes("handleInput"),
"overlay has handleInput method",
@ -174,6 +241,31 @@ assertTrue(
"overlay tracks per-tab scroll offsets",
);
assertTrue(
overlaySrc.includes("filterMode"),
"overlay has filterMode state",
);
assertTrue(
overlaySrc.includes("filterText"),
"overlay has filterText state",
);
assertTrue(
overlaySrc.includes("filterField"),
"overlay has filterField state",
);
assertTrue(
overlaySrc.includes("TAB_COUNT"),
"overlay defines TAB_COUNT",
);
assertTrue(
overlaySrc.includes("7 Export"),
"overlay has 7 tab labels",
);
// Verify commands.ts integration
const commandsPath = join(__dirname, "..", "commands.ts");
const commandsSrc = readFileSync(commandsPath, "utf-8");

View file

@ -0,0 +1,120 @@
// Tests for GSD visualizer overlay.
// Verifies filter mode, tab switching, and export key handling.
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createTestContext } from "./test-helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const { assertTrue, assertEq, report } = createTestContext();
const overlaySrc = readFileSync(join(__dirname, "..", "visualizer-overlay.ts"), "utf-8");
console.log("\n=== Overlay: Tab Configuration ===");
assertTrue(
overlaySrc.includes("TAB_COUNT = 7"),
"TAB_COUNT is 7",
);
assertTrue(
overlaySrc.includes('"1 Progress"'),
"has Progress tab label",
);
assertTrue(
overlaySrc.includes('"5 Agent"'),
"has Agent tab label",
);
assertTrue(
overlaySrc.includes('"6 Changes"'),
"has Changes tab label",
);
assertTrue(
overlaySrc.includes('"7 Export"'),
"has Export tab label",
);
console.log("\n=== Overlay: Filter Mode ===");
assertTrue(
overlaySrc.includes('filterMode = false'),
"filterMode initialized to false",
);
assertTrue(
overlaySrc.includes('filterText = ""'),
"filterText initialized to empty string",
);
assertTrue(
overlaySrc.includes('filterField:'),
"has filterField state",
);
// Filter mode entry via "/"
assertTrue(
overlaySrc.includes('data === "/"') || overlaySrc.includes("data === '/'"),
"/ key enters filter mode",
);
// Filter field cycling via "f"
assertTrue(
overlaySrc.includes('data === "f"') || overlaySrc.includes("data === 'f'"),
"f key cycles filter field",
);
console.log("\n=== Overlay: Tab Switching ===");
// Supports 1-7 keys
assertTrue(
overlaySrc.includes('"1234567"'),
"supports keys 1-7 for tab switching",
);
// Tab wraps with TAB_COUNT
assertTrue(
overlaySrc.includes("% TAB_COUNT"),
"tab key wraps around TAB_COUNT",
);
console.log("\n=== Overlay: Export Key Interception ===");
assertTrue(
overlaySrc.includes("activeTab === 6"),
"export key handling checks for tab 7 (index 6)",
);
assertTrue(
overlaySrc.includes('handleExportKey'),
"has handleExportKey method",
);
assertTrue(
overlaySrc.includes('"m"') && overlaySrc.includes('"j"') && overlaySrc.includes('"s"'),
"handles m, j, s keys for export",
);
console.log("\n=== Overlay: Footer ===");
assertTrue(
overlaySrc.includes("Tab/1-7"),
"footer hint shows 1-7 tab range",
);
assertTrue(
overlaySrc.includes("/ filter"),
"footer hint mentions filter",
);
console.log("\n=== Overlay: Scroll Offsets ===");
assertTrue(
overlaySrc.includes(`new Array(TAB_COUNT).fill(0)`),
"scroll offsets sized to TAB_COUNT",
);
report();

View file

@ -6,6 +6,9 @@ import {
renderDepsView,
renderMetricsView,
renderTimelineView,
renderAgentView,
renderChangelogView,
renderExportView,
} from "../visualizer-views.js";
import type { VisualizerData } from "../visualizer-data.js";
import { createTestContext } from "./test-helpers.ts";
@ -30,6 +33,15 @@ function makeVisualizerData(overrides: Partial<VisualizerData> = {}): Visualizer
bySlice: [],
byModel: [],
units: [],
criticalPath: {
milestonePath: [],
slicePath: [],
milestoneSlack: new Map(),
sliceSlack: new Map(),
},
remainingSliceCount: 0,
agentActivity: null,
changelog: { entries: [] },
...overrides,
};
}
@ -104,6 +116,73 @@ console.log("\n=== renderProgressView ===");
assertEq(lines.length, 0, "empty milestones produce no lines");
}
// ─── Risk Heatmap ───────────────────────────────────────────────────────────
console.log("\n=== Risk Heatmap ===");
{
const data = makeVisualizerData({
milestones: [
{
id: "M001",
title: "First",
status: "active",
dependsOn: [],
slices: [
{ id: "S01", title: "A", done: true, active: false, risk: "low", depends: [], tasks: [] },
{ id: "S02", title: "B", done: false, active: true, risk: "high", depends: [], tasks: [] },
{ id: "S03", title: "C", done: false, active: false, risk: "medium", depends: [], tasks: [] },
{ id: "S04", title: "D", done: false, active: false, risk: "high", depends: [], tasks: [] },
],
},
],
});
const lines = renderProgressView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("Risk Heatmap")), "heatmap header present");
assertTrue(lines.some(l => l.includes("██")), "heatmap has colored blocks");
assertTrue(lines.some(l => l.includes("low") && l.includes("med") && l.includes("high")), "heatmap legend present");
assertTrue(lines.some(l => l.includes("1 low, 1 med, 2 high")), "risk summary counts");
assertTrue(lines.some(l => l.includes("1 high-risk not started")), "high-risk not started warning");
}
// ─── Search/Filter ──────────────────────────────────────────────────────────
console.log("\n=== Search/Filter ===");
{
const data = makeVisualizerData({
milestones: [
{
id: "M001",
title: "Auth",
status: "active",
dependsOn: [],
slices: [
{ id: "S01", title: "JWT", done: false, active: false, risk: "low", depends: [], tasks: [] },
{ id: "S02", title: "OAuth", done: false, active: false, risk: "high", depends: [], tasks: [] },
],
},
{
id: "M002",
title: "Dashboard",
status: "pending",
dependsOn: ["M001"],
slices: [],
},
],
});
// Filter by keyword "auth"
const filtered = renderProgressView(data, mockTheme, 80, { text: "auth", field: "all" });
assertTrue(filtered.some(l => l.includes("M001")), "filter shows matching milestone");
assertTrue(filtered.some(l => l.includes("Filter (all): auth")), "filter indicator present");
// Filter by risk "high"
const riskFiltered = renderProgressView(data, mockTheme, 80, { text: "high", field: "risk" });
assertTrue(riskFiltered.some(l => l.includes("M001")), "risk filter shows milestone with high-risk slice");
}
// ─── renderDepsView ─────────────────────────────────────────────────────────
console.log("\n=== renderDepsView ===");
@ -129,12 +208,20 @@ console.log("\n=== renderDepsView ===");
slices: [],
},
],
criticalPath: {
milestonePath: ["M001", "M002"],
slicePath: ["S01", "S02"],
milestoneSlack: new Map([["M001", 0], ["M002", 0]]),
sliceSlack: new Map([["S01", 0], ["S02", 0]]),
},
});
const lines = renderDepsView(data, mockTheme, 80);
assertTrue(lines.length > 0, "deps view produces output");
assertTrue(lines.some(l => l.includes("M001") && l.includes("M002")), "shows milestone dep edge");
assertTrue(lines.some(l => l.includes("S01") && l.includes("S02")), "shows slice dep edge");
assertTrue(lines.some(l => l.includes("Critical Path")), "shows critical path section");
assertTrue(lines.some(l => l.includes("[CRITICAL]")), "shows CRITICAL badge");
}
{
@ -187,6 +274,11 @@ console.log("\n=== renderMetricsView ===");
cost: 2.50,
},
],
bySlice: [
{ sliceId: "M001/S01", units: 3, tokens: { input: 600, output: 300, cacheRead: 100, cacheWrite: 50, total: 1050 }, cost: 1.50, duration: 40000 },
{ sliceId: "M001/S02", units: 2, tokens: { input: 400, output: 200, cacheRead: 100, cacheWrite: 50, total: 750 }, cost: 1.00, duration: 20000 },
],
remainingSliceCount: 3,
});
const lines = renderMetricsView(data, mockTheme, 80);
@ -194,6 +286,11 @@ console.log("\n=== renderMetricsView ===");
assertTrue(lines.some(l => l.includes("$2.50")), "shows total cost");
assertTrue(lines.some(l => l.includes("execution")), "shows phase name");
assertTrue(lines.some(l => l.includes("claude-opus-4-6")), "shows model name");
assertTrue(lines.some(l => l.includes("Projections")), "shows projections section");
assertTrue(lines.some(l => l.includes("Avg cost/slice")), "shows avg cost per slice");
assertTrue(lines.some(l => l.includes("Projected remaining")), "shows projected remaining");
assertTrue(lines.some(l => l.includes("Burn rate")), "shows burn rate");
assertTrue(lines.some(l => l.includes("Cost trend")), "shows sparkline");
}
{
@ -237,11 +334,16 @@ console.log("\n=== renderTimelineView ===");
],
});
const lines = renderTimelineView(data, mockTheme, 80);
assertTrue(lines.length >= 2, "timeline view produces lines for each unit");
assertTrue(lines.some(l => l.includes("execute-task")), "shows unit type");
assertTrue(lines.some(l => l.includes("M001/S01/T01")), "shows unit id");
assertTrue(lines.some(l => l.includes("$0.42")), "shows unit cost");
// Wide terminal — Gantt view
const ganttLines = renderTimelineView(data, mockTheme, 120);
assertTrue(ganttLines.length >= 2, "gantt view produces lines for each unit");
// Narrow terminal — list view
const listLines = renderTimelineView(data, mockTheme, 80);
assertTrue(listLines.length >= 2, "list view produces lines for each unit");
assertTrue(listLines.some(l => l.includes("execute-task")), "shows unit type");
assertTrue(listLines.some(l => l.includes("M001/S01/T01")), "shows unit id");
assertTrue(listLines.some(l => l.includes("$0.42")), "shows unit cost");
}
{
@ -250,6 +352,125 @@ console.log("\n=== renderTimelineView ===");
assertTrue(lines.some(l => l.includes("No execution history")), "shows empty message");
}
// ─── renderAgentView ────────────────────────────────────────────────────────
console.log("\n=== renderAgentView ===");
{
const now = Date.now();
const data = makeVisualizerData({
agentActivity: {
currentUnit: { type: "execute-task", id: "M001/S02/T03", startedAt: now - 60000 },
elapsed: 60000,
completedUnits: 8,
totalSlices: 15,
completionRate: 2.4,
active: true,
sessionCost: 1.23,
sessionTokens: 45200,
},
units: [
{
type: "execute-task", id: "M001/S01/T01", model: "claude-opus-4-6",
startedAt: now - 300000, finishedAt: now - 240000,
tokens: { input: 500, output: 200, cacheRead: 100, cacheWrite: 50, total: 850 },
cost: 0.12, toolCalls: 5, assistantMessages: 3, userMessages: 1,
},
],
});
const lines = renderAgentView(data, mockTheme, 80);
assertTrue(lines.length > 0, "agent view produces output");
assertTrue(lines.some(l => l.includes("ACTIVE")), "shows active status");
assertTrue(lines.some(l => l.includes("M001/S02/T03")), "shows current unit");
assertTrue(lines.some(l => l.includes("8/15")), "shows progress fraction");
assertTrue(lines.some(l => l.includes("2.4 units/hr")), "shows completion rate");
assertTrue(lines.some(l => l.includes("$1.23")), "shows session cost");
}
{
const data = makeVisualizerData({ agentActivity: null });
const lines = renderAgentView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("No agent activity")), "shows no-activity message");
}
{
const data = makeVisualizerData({
agentActivity: {
currentUnit: null,
elapsed: 0,
completedUnits: 5,
totalSlices: 10,
completionRate: 1.5,
active: false,
sessionCost: 0.50,
sessionTokens: 20000,
},
});
const lines = renderAgentView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("IDLE")), "shows idle status");
assertTrue(lines.some(l => l.includes("Not in auto mode")), "shows not-in-auto message");
}
// ─── renderChangelogView ────────────────────────────────────────────────────
console.log("\n=== renderChangelogView ===");
{
const data = makeVisualizerData({
changelog: {
entries: [
{
milestoneId: "M001",
sliceId: "S01",
title: "Core Authentication Setup",
oneLiner: "Added JWT-based auth with refresh token rotation",
filesModified: [
{ path: "src/auth/jwt.ts", description: "JWT token generation and validation" },
{ path: "src/auth/middleware.ts", description: "Express middleware for auth checks" },
],
completedAt: "2026-03-15T14:30:00Z",
},
],
},
});
const lines = renderChangelogView(data, mockTheme, 80);
assertTrue(lines.length > 0, "changelog view produces output");
assertTrue(lines.some(l => l.includes("M001/S01")), "shows slice reference");
assertTrue(lines.some(l => l.includes("Core Authentication Setup")), "shows entry title");
assertTrue(lines.some(l => l.includes("JWT-based auth")), "shows one-liner");
assertTrue(lines.some(l => l.includes("src/auth/jwt.ts")), "shows modified file");
assertTrue(lines.some(l => l.includes("2026-03-15")), "shows completed date");
}
{
const data = makeVisualizerData({ changelog: { entries: [] } });
const lines = renderChangelogView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("No completed slices")), "shows empty state");
}
// ─── renderExportView ───────────────────────────────────────────────────────
console.log("\n=== renderExportView ===");
{
const data = makeVisualizerData();
const lines = renderExportView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("Export Options")), "shows export header");
assertTrue(lines.some(l => l.includes("[m]")), "shows markdown option");
assertTrue(lines.some(l => l.includes("[j]")), "shows json option");
assertTrue(lines.some(l => l.includes("[s]")), "shows snapshot option");
}
{
const data = makeVisualizerData();
const lines = renderExportView(data, mockTheme, 80, "/tmp/export-2026.md");
assertTrue(lines.some(l => l.includes("Last export:")), "shows last export path");
assertTrue(lines.some(l => l.includes("/tmp/export-2026.md")), "shows specific export path");
}
// ─── Report ─────────────────────────────────────────────────────────────────
report();

View file

@ -1,7 +1,7 @@
// Data loader for workflow visualizer overlay — aggregates state + metrics.
import { deriveState } from './state.js';
import { parseRoadmap, parsePlan, loadFile } from './files.js';
import { parseRoadmap, parsePlan, parseSummary, loadFile } from './files.js';
import { findMilestoneIds } from './guided-flow.js';
import { resolveMilestoneFile, resolveSliceFile } from './paths.js';
import {
@ -11,6 +11,7 @@ import {
aggregateBySlice,
aggregateByModel,
loadLedgerFromDisk,
classifyUnitPhase,
} from './metrics.js';
import type { Phase } from './types.js';
@ -49,6 +50,37 @@ export interface VisualizerTask {
active: boolean;
}
export interface CriticalPathInfo {
milestonePath: string[];
slicePath: string[];
milestoneSlack: Map<string, number>;
sliceSlack: Map<string, number>;
}
export interface AgentActivityInfo {
currentUnit: { type: string; id: string; startedAt: number } | null;
elapsed: number;
completedUnits: number;
totalSlices: number;
completionRate: number;
active: boolean;
sessionCost: number;
sessionTokens: number;
}
export interface ChangelogEntry {
milestoneId: string;
sliceId: string;
title: string;
oneLiner: string;
filesModified: { path: string; description: string }[];
completedAt: string;
}
export interface ChangelogInfo {
entries: ChangelogEntry[];
}
export interface VisualizerData {
milestones: VisualizerMilestone[];
phase: Phase;
@ -57,6 +89,308 @@ export interface VisualizerData {
bySlice: SliceAggregate[];
byModel: ModelAggregate[];
units: UnitMetrics[];
criticalPath: CriticalPathInfo;
remainingSliceCount: number;
agentActivity: AgentActivityInfo | null;
changelog: ChangelogInfo;
}
// ─── Critical Path ────────────────────────────────────────────────────────────
export function computeCriticalPath(milestones: VisualizerMilestone[]): CriticalPathInfo {
const empty: CriticalPathInfo = {
milestonePath: [],
slicePath: [],
milestoneSlack: new Map(),
sliceSlack: new Map(),
};
if (milestones.length === 0) return empty;
// Milestone-level critical path (weight = number of incomplete slices)
const msMap = new Map(milestones.map(m => [m.id, m]));
const msIds = milestones.map(m => m.id);
const msAdj = new Map<string, string[]>();
const msWeight = new Map<string, number>();
for (const ms of milestones) {
msAdj.set(ms.id, []);
const incomplete = ms.slices.filter(s => !s.done).length;
msWeight.set(ms.id, ms.status === 'complete' ? 0 : Math.max(1, incomplete));
}
for (const ms of milestones) {
for (const dep of ms.dependsOn) {
if (msMap.has(dep)) {
const adj = msAdj.get(dep);
if (adj) adj.push(ms.id);
}
}
}
// Topological sort (Kahn's algorithm)
const inDegree = new Map<string, number>();
for (const id of msIds) inDegree.set(id, 0);
for (const ms of milestones) {
for (const dep of ms.dependsOn) {
if (msMap.has(dep)) inDegree.set(ms.id, (inDegree.get(ms.id) ?? 0) + 1);
}
}
const queue: string[] = [];
for (const [id, deg] of inDegree) {
if (deg === 0) queue.push(id);
}
const topoOrder: string[] = [];
while (queue.length > 0) {
const node = queue.shift()!;
topoOrder.push(node);
for (const next of (msAdj.get(node) ?? [])) {
const d = (inDegree.get(next) ?? 1) - 1;
inDegree.set(next, d);
if (d === 0) queue.push(next);
}
}
// Longest path from each root
const dist = new Map<string, number>();
const prev = new Map<string, string | null>();
for (const id of msIds) {
dist.set(id, 0);
prev.set(id, null);
}
for (const node of topoOrder) {
const w = msWeight.get(node) ?? 1;
const nodeDist = dist.get(node)! + w;
for (const next of (msAdj.get(node) ?? [])) {
if (nodeDist > dist.get(next)!) {
dist.set(next, nodeDist);
prev.set(next, node);
}
}
}
// Find the end of the critical path (node with max dist + own weight)
let maxDist = 0;
let endNode = msIds[0];
for (const id of msIds) {
const totalDist = dist.get(id)! + (msWeight.get(id) ?? 1);
if (totalDist > maxDist) {
maxDist = totalDist;
endNode = id;
}
}
// Trace back
const milestonePath: string[] = [];
let cur: string | null = endNode;
while (cur !== null) {
milestonePath.unshift(cur);
cur = prev.get(cur) ?? null;
}
// Compute milestone slack
const milestoneSlack = new Map<string, number>();
const criticalSet = new Set(milestonePath);
for (const id of msIds) {
if (criticalSet.has(id)) {
milestoneSlack.set(id, 0);
} else {
const nodeTotal = dist.get(id)! + (msWeight.get(id) ?? 1);
milestoneSlack.set(id, Math.max(0, maxDist - nodeTotal));
}
}
// Slice-level critical path within active milestone
const activeMs = milestones.find(m => m.status === 'active');
let slicePath: string[] = [];
const sliceSlack = new Map<string, number>();
if (activeMs && activeMs.slices.length > 0) {
const slMap = new Map(activeMs.slices.map(s => [s.id, s]));
const slAdj = new Map<string, string[]>();
for (const s of activeMs.slices) slAdj.set(s.id, []);
for (const s of activeMs.slices) {
for (const dep of s.depends) {
if (slMap.has(dep)) {
const adj = slAdj.get(dep);
if (adj) adj.push(s.id);
}
}
}
// Topo sort slices
const slIn = new Map<string, number>();
for (const s of activeMs.slices) slIn.set(s.id, 0);
for (const s of activeMs.slices) {
for (const dep of s.depends) {
if (slMap.has(dep)) slIn.set(s.id, (slIn.get(s.id) ?? 0) + 1);
}
}
const slQueue: string[] = [];
for (const [id, d] of slIn) {
if (d === 0) slQueue.push(id);
}
const slTopo: string[] = [];
while (slQueue.length > 0) {
const n = slQueue.shift()!;
slTopo.push(n);
for (const next of (slAdj.get(n) ?? [])) {
const d = (slIn.get(next) ?? 1) - 1;
slIn.set(next, d);
if (d === 0) slQueue.push(next);
}
}
const slDist = new Map<string, number>();
const slPrev = new Map<string, string | null>();
for (const s of activeMs.slices) {
const w = s.done ? 0 : 1;
slDist.set(s.id, 0);
slPrev.set(s.id, null);
}
for (const n of slTopo) {
const w = (slMap.get(n)?.done ? 0 : 1);
const nd = slDist.get(n)! + w;
for (const next of (slAdj.get(n) ?? [])) {
if (nd > slDist.get(next)!) {
slDist.set(next, nd);
slPrev.set(next, n);
}
}
}
let slMax = 0;
let slEnd = activeMs.slices[0].id;
for (const s of activeMs.slices) {
const totalDist = slDist.get(s.id)! + (s.done ? 0 : 1);
if (totalDist > slMax) {
slMax = totalDist;
slEnd = s.id;
}
}
let slCur: string | null = slEnd;
while (slCur !== null) {
slicePath.unshift(slCur);
slCur = slPrev.get(slCur) ?? null;
}
const slCritSet = new Set(slicePath);
for (const s of activeMs.slices) {
if (slCritSet.has(s.id)) {
sliceSlack.set(s.id, 0);
} else {
const nodeTotal = slDist.get(s.id)! + (s.done ? 0 : 1);
sliceSlack.set(s.id, Math.max(0, slMax - nodeTotal));
}
}
}
return { milestonePath, slicePath, milestoneSlack, sliceSlack };
}
// ─── Agent Activity ──────────────────────────────────────────────────────────
function loadAgentActivity(units: UnitMetrics[], milestones: VisualizerMilestone[]): AgentActivityInfo | null {
if (units.length === 0) return null;
// Find currently running unit (finishedAt === 0)
const running = units.find(u => u.finishedAt === 0);
const now = Date.now();
const completedUnits = units.filter(u => u.finishedAt > 0).length;
const totalSlices = milestones.reduce((sum, m) => sum + m.slices.length, 0);
// Completion rate from finished units
const finished = units.filter(u => u.finishedAt > 0);
let completionRate = 0;
if (finished.length >= 2) {
const earliest = Math.min(...finished.map(u => u.startedAt));
const latest = Math.max(...finished.map(u => u.finishedAt));
const totalHours = (latest - earliest) / 3_600_000;
completionRate = totalHours > 0 ? finished.length / totalHours : 0;
}
const sessionCost = units.reduce((sum, u) => sum + u.cost, 0);
const sessionTokens = units.reduce((sum, u) => sum + u.tokens.total, 0);
return {
currentUnit: running
? { type: running.type, id: running.id, startedAt: running.startedAt }
: null,
elapsed: running ? now - running.startedAt : 0,
completedUnits,
totalSlices,
completionRate,
active: !!running,
sessionCost,
sessionTokens,
};
}
// ─── Changelog ───────────────────────────────────────────────────────────────
const changelogCache = new Map<string, { mtime: number; entry: ChangelogEntry }>();
async function loadChangelog(basePath: string, milestones: VisualizerMilestone[]): Promise<ChangelogInfo> {
const entries: ChangelogEntry[] = [];
for (const ms of milestones) {
for (const sl of ms.slices) {
if (!sl.done) continue;
const summaryFile = resolveSliceFile(basePath, ms.id, sl.id, 'SUMMARY');
if (!summaryFile) continue;
// Check cache by file path
const cacheKey = `${ms.id}/${sl.id}`;
const cached = changelogCache.get(cacheKey);
// Check mtime for cache invalidation
let mtime = 0;
try {
const { statSync } = await import('node:fs');
mtime = statSync(summaryFile).mtimeMs;
} catch {
continue;
}
if (cached && cached.mtime === mtime) {
entries.push(cached.entry);
continue;
}
const content = await loadFile(summaryFile);
if (!content) continue;
const summary = parseSummary(content);
const entry: ChangelogEntry = {
milestoneId: ms.id,
sliceId: sl.id,
title: sl.title,
oneLiner: summary.oneLiner,
filesModified: summary.filesModified.map(f => ({
path: f.path,
description: f.description,
})),
completedAt: summary.frontmatter.completed_at ?? '',
};
changelogCache.set(cacheKey, { mtime, entry });
entries.push(entry);
}
}
// Sort by completedAt descending
entries.sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
return { entries };
}
// ─── Loader ───────────────────────────────────────────────────────────────────
@ -142,6 +476,19 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
byModel = aggregateByModel(units);
}
// Compute new fields
const criticalPath = computeCriticalPath(milestones);
let remainingSliceCount = 0;
for (const ms of milestones) {
for (const sl of ms.slices) {
if (!sl.done) remainingSliceCount++;
}
}
const agentActivity = loadAgentActivity(units, milestones);
const changelog = await loadChangelog(basePath, milestones);
return {
milestones,
phase: state.phase,
@ -150,5 +497,9 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
bySlice,
byModel,
units,
criticalPath,
remainingSliceCount,
agentActivity,
changelog,
};
}

View file

@ -6,9 +6,23 @@ import {
renderDepsView,
renderMetricsView,
renderTimelineView,
renderAgentView,
renderChangelogView,
renderExportView,
type ProgressFilter,
} from "./visualizer-views.js";
import { writeExportFile } from "./export.js";
const TAB_LABELS = ["1 Progress", "2 Deps", "3 Metrics", "4 Timeline"];
const TAB_COUNT = 7;
const TAB_LABELS = [
"1 Progress",
"2 Deps",
"3 Metrics",
"4 Timeline",
"5 Agent",
"6 Changes",
"7 Export",
];
export class GSDVisualizerOverlay {
private tui: { requestRender: () => void };
@ -16,7 +30,7 @@ export class GSDVisualizerOverlay {
private onClose: () => void;
activeTab = 0;
scrollOffsets: number[] = [0, 0, 0, 0];
scrollOffsets: number[] = new Array(TAB_COUNT).fill(0);
loading = true;
disposed = false;
cachedWidth?: number;
@ -25,6 +39,15 @@ export class GSDVisualizerOverlay {
data: VisualizerData | null = null;
basePath: string;
// Filter state (Progress tab)
filterMode = false;
filterText = "";
filterField: "all" | "status" | "risk" | "keyword" = "all";
// Export state
lastExportPath?: string;
exportStatus?: string;
constructor(
tui: { requestRender: () => void },
theme: Theme,
@ -52,6 +75,37 @@ export class GSDVisualizerOverlay {
}
handleInput(data: string): void {
// Filter mode input routing
if (this.filterMode) {
if (matchesKey(data, Key.escape)) {
this.filterMode = false;
this.filterText = "";
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.enter)) {
this.filterMode = false;
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.backspace)) {
this.filterText = this.filterText.slice(0, -1);
this.invalidate();
this.tui.requestRender();
return;
}
// Append printable characters
if (data.length === 1 && data.charCodeAt(0) >= 32) {
this.filterText += data;
this.invalidate();
this.tui.requestRender();
return;
}
return;
}
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.dispose();
this.onClose();
@ -59,19 +113,46 @@ export class GSDVisualizerOverlay {
}
if (matchesKey(data, Key.tab)) {
this.activeTab = (this.activeTab + 1) % 4;
this.activeTab = (this.activeTab + 1) % TAB_COUNT;
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "1" || data === "2" || data === "3" || data === "4") {
if ("1234567".includes(data) && data.length === 1) {
this.activeTab = parseInt(data, 10) - 1;
this.invalidate();
this.tui.requestRender();
return;
}
// "/" enters filter mode on Progress tab
if (data === "/" && this.activeTab === 0) {
this.filterMode = true;
this.filterText = "";
this.invalidate();
this.tui.requestRender();
return;
}
// "f" cycles filter field on Progress tab (when not in filter mode)
if (data === "f" && this.activeTab === 0) {
const fields: Array<"all" | "status" | "risk" | "keyword"> = ["all", "status", "risk", "keyword"];
const idx = fields.indexOf(this.filterField);
this.filterField = fields[(idx + 1) % fields.length];
this.invalidate();
this.tui.requestRender();
return;
}
// Export tab key handling
if (this.activeTab === 6 && this.data) {
if (data === "m" || data === "j" || data === "s") {
this.handleExportKey(data);
return;
}
}
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
this.scrollOffsets[this.activeTab]++;
this.invalidate();
@ -101,6 +182,62 @@ export class GSDVisualizerOverlay {
}
}
private handleExportKey(key: "m" | "j" | "s"): void {
if (!this.data) return;
const format = key === "m" ? "markdown" : key === "j" ? "json" : "snapshot";
if (format === "snapshot") {
// Capture current active tab's rendered lines as snapshot
const snapshotLines = this.renderTabContent(this.activeTab, 80);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const { writeFileSync, mkdirSync } = require("node:fs");
const { join } = require("node:path");
const { gsdRoot } = require("./paths.js");
const exportDir = gsdRoot(this.basePath);
mkdirSync(exportDir, { recursive: true });
const outPath = join(exportDir, `snapshot-${timestamp}.txt`);
writeFileSync(outPath, snapshotLines.join("\n") + "\n", "utf-8");
this.lastExportPath = outPath;
this.exportStatus = "Snapshot saved";
} else {
const result = writeExportFile(this.basePath, format, this.data);
if (result) {
this.lastExportPath = result;
this.exportStatus = `${format} export saved`;
}
}
this.invalidate();
this.tui.requestRender();
}
private renderTabContent(tab: number, width: number): string[] {
if (!this.data) return [];
const th = this.theme;
switch (tab) {
case 0: {
const filter: ProgressFilter | undefined =
this.filterText ? { text: this.filterText, field: this.filterField } : undefined;
return renderProgressView(this.data, th, width, filter);
}
case 1:
return renderDepsView(this.data, th, width);
case 2:
return renderMetricsView(this.data, th, width);
case 3:
return renderTimelineView(this.data, th, width);
case 4:
return renderAgentView(this.data, th, width);
case 5:
return renderChangelogView(this.data, th, width);
case 6:
return renderExportView(this.data, th, width, this.lastExportPath);
default:
return [];
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
@ -112,35 +249,42 @@ export class GSDVisualizerOverlay {
// Tab bar
const tabs = TAB_LABELS.map((label, i) => {
if (i === this.activeTab) {
return th.fg("accent", `[${label}]`);
let displayLabel = label;
// Show filter indicator on Progress tab
if (i === 0 && this.filterText) {
displayLabel += " ✱";
}
return th.fg("dim", `[${label}]`);
if (i === this.activeTab) {
return th.fg("accent", `[${displayLabel}]`);
}
return th.fg("dim", `[${displayLabel}]`);
});
content.push(" " + tabs.join(" "));
content.push(" " + tabs.join(" "));
content.push("");
// Filter bar (when in filter mode)
if (this.filterMode && this.activeTab === 0) {
content.push(
th.fg("accent", `Filter (${this.filterField}): ${this.filterText}`),
);
content.push("");
}
if (this.loading) {
const loadingText = "Loading…";
const vis = visibleWidth(loadingText);
const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2));
content.push(" ".repeat(leftPad) + loadingText);
} else if (this.data) {
let viewLines: string[] = [];
switch (this.activeTab) {
case 0:
viewLines = renderProgressView(this.data, th, innerWidth);
break;
case 1:
viewLines = renderDepsView(this.data, th, innerWidth);
break;
case 2:
viewLines = renderMetricsView(this.data, th, innerWidth);
break;
case 3:
viewLines = renderTimelineView(this.data, th, innerWidth);
break;
const viewLines = this.renderTabContent(this.activeTab, innerWidth);
// Show export status message if present
if (this.exportStatus && this.activeTab === 6) {
content.push(th.fg("success", this.exportStatus));
content.push("");
this.exportStatus = undefined;
}
content.push(...viewLines);
}
@ -156,7 +300,7 @@ export class GSDVisualizerOverlay {
const lines = this.wrapInBox(visibleContent, width);
// Footer hint
const hint = th.fg("dim", "Tab/1-4 switch · ↑↓ scroll · g/G top/end · esc close");
const hint = th.fg("dim", "Tab/1-7 switch · / filter · ↑↓ scroll · g/G top/end · esc close");
const hintVis = visibleWidth(hint);
const hintPad = Math.max(0, Math.floor((width - hintVis) / 2));
lines.push(" ".repeat(hintPad) + hint);

View file

@ -3,7 +3,7 @@
import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import type { VisualizerData, VisualizerMilestone } from "./visualizer-data.js";
import { formatCost, formatTokenCount } from "./metrics.js";
import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js";
// ─── Local Helpers ───────────────────────────────────────────────────────────
@ -32,16 +32,46 @@ function joinColumns(left: string, right: string, width: number): string {
return left + " ".repeat(width - leftW - rightW) + right;
}
function sparkline(values: number[]): string {
if (values.length === 0) return "";
const chars = "▁▂▃▄▅▆▇█";
const max = Math.max(...values);
if (max === 0) return chars[0].repeat(values.length);
return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join("");
}
// ─── Progress View ───────────────────────────────────────────────────────────
export interface ProgressFilter {
text: string;
field: "all" | "status" | "risk" | "keyword";
}
export function renderProgressView(
data: VisualizerData,
th: Theme,
width: number,
filter?: ProgressFilter,
): string[] {
const lines: string[] = [];
// Risk Heatmap
lines.push(...renderRiskHeatmap(data, th, width));
if (data.milestones.length > 0) lines.push("");
// Filter indicator
if (filter && filter.text) {
lines.push(th.fg("accent", `Filter (${filter.field}): ${filter.text}`));
lines.push("");
}
for (const ms of data.milestones) {
// Apply filter to milestones
if (filter && filter.text) {
const matchesMs = matchesFilter(ms, filter);
if (!matchesMs) continue;
}
// Milestone header line
const statusGlyph =
ms.status === "complete"
@ -70,6 +100,11 @@ export function renderProgressView(
}
for (const sl of ms.slices) {
// Apply filter to slices
if (filter && filter.text) {
if (!matchesSliceFilter(sl, filter)) continue;
}
// Slice line
const slGlyph = sl.done
? th.fg("success", "✓")
@ -103,6 +138,78 @@ export function renderProgressView(
return lines;
}
function matchesFilter(ms: VisualizerMilestone, filter: ProgressFilter): boolean {
const text = filter.text.toLowerCase();
if (filter.field === "status") {
return ms.status.includes(text);
}
if (filter.field === "risk") {
return ms.slices.some(s => s.risk.toLowerCase().includes(text));
}
// "all" or "keyword"
if (ms.id.toLowerCase().includes(text)) return true;
if (ms.title.toLowerCase().includes(text)) return true;
if (ms.status.includes(text)) return true;
return ms.slices.some(s => matchesSliceFilter(s, filter));
}
function matchesSliceFilter(sl: { id: string; title: string; risk: string }, filter: ProgressFilter): boolean {
const text = filter.text.toLowerCase();
if (filter.field === "status") return true; // slices don't have named status
if (filter.field === "risk") return sl.risk.toLowerCase().includes(text);
return sl.id.toLowerCase().includes(text) ||
sl.title.toLowerCase().includes(text) ||
sl.risk.toLowerCase().includes(text);
}
// ─── Risk Heatmap ────────────────────────────────────────────────────────────
function renderRiskHeatmap(data: VisualizerData, th: Theme, width: number): string[] {
const allSlices = data.milestones.flatMap(m => m.slices);
if (allSlices.length === 0) return [];
const lines: string[] = [];
lines.push(th.fg("accent", th.bold("Risk Heatmap")));
lines.push("");
for (const ms of data.milestones) {
if (ms.slices.length === 0) continue;
const blocks = ms.slices.map(s => {
const color = s.risk === "high" ? "error" : s.risk === "medium" ? "warning" : "success";
return th.fg(color, "██");
});
const row = ` ${padRight(ms.id, 6)} ${blocks.join(" ")}`;
lines.push(truncateToWidth(row, width));
}
lines.push("");
lines.push(
` ${th.fg("success", "██")} low ${th.fg("warning", "██")} med ${th.fg("error", "██")} high`,
);
// Summary counts
let low = 0, med = 0, high = 0;
let highNotStarted = 0;
for (const sl of allSlices) {
if (sl.risk === "high") {
high++;
if (!sl.done && !sl.active) highNotStarted++;
} else if (sl.risk === "medium") {
med++;
} else {
low++;
}
}
let summary = ` Risk: ${low} low, ${med} med, ${high} high`;
if (highNotStarted > 0) {
summary += ` | ${th.fg("error", `${highNotStarted} high-risk not started`)}`;
}
lines.push(summary);
return lines;
}
// ─── Dependencies View ───────────────────────────────────────────────────────
export function renderDepsView(
@ -153,6 +260,65 @@ export function renderDepsView(
}
}
lines.push("");
// Critical Path section
lines.push(...renderCriticalPath(data, th, width));
return lines;
}
// ─── Critical Path ───────────────────────────────────────────────────────────
function renderCriticalPath(data: VisualizerData, th: Theme, _width: number): string[] {
const lines: string[] = [];
const cp = data.criticalPath;
lines.push(th.fg("accent", th.bold("Critical Path")));
lines.push("");
if (cp.milestonePath.length === 0) {
lines.push(th.fg("dim", " No critical path data."));
return lines;
}
// Milestone chain
const chain = cp.milestonePath.map(id => {
const ms = data.milestones.find(m => m.id === id);
const badge = th.fg("error", "[CRITICAL]");
return `${id} ${badge}`;
}).join(` ${th.fg("accent", "──►")} `);
lines.push(` ${chain}`);
lines.push("");
// Non-critical milestones with slack
for (const ms of data.milestones) {
if (cp.milestonePath.includes(ms.id)) continue;
const slack = cp.milestoneSlack.get(ms.id) ?? 0;
lines.push(th.fg("dim", ` ${ms.id} (slack: ${slack})`));
}
// Slice-level critical path
if (cp.slicePath.length > 0) {
lines.push("");
lines.push(th.fg("accent", th.bold("Slice Critical Path")));
lines.push("");
const sliceChain = cp.slicePath.join(` ${th.fg("accent", "──►")} `);
lines.push(` ${sliceChain}`);
// Bottleneck warnings
const activeMs = data.milestones.find(m => m.status === "active");
if (activeMs) {
for (const sid of cp.slicePath) {
const sl = activeMs.slices.find(s => s.id === sid);
if (sl && !sl.done && !sl.active) {
lines.push(th.fg("warning", `${sid}: critical but not yet started`));
}
}
}
}
return lines;
}
@ -232,12 +398,66 @@ export function renderMetricsView(
const pctStr = `${pct.toFixed(1)}%`;
lines.push(` ${label} ${bar} ${costStr} ${pctStr}`);
}
lines.push("");
}
// Cost Projections
lines.push(...renderCostProjections(data, th, width));
return lines;
}
// ─── Cost Projections ────────────────────────────────────────────────────────
function renderCostProjections(data: VisualizerData, th: Theme, _width: number): string[] {
const lines: string[] = [];
if (!data.totals || data.bySlice.length === 0) return lines;
lines.push(th.fg("accent", th.bold("Projections")));
lines.push("");
// Average cost per slice
const sliceLevelEntries = data.bySlice.filter(s => s.sliceId.includes("/"));
if (sliceLevelEntries.length < 2) {
lines.push(th.fg("dim", " Insufficient data for projections (need 2+ completed slices)."));
return lines;
}
const totalSliceCost = sliceLevelEntries.reduce((sum, s) => sum + s.cost, 0);
const avgCostPerSlice = totalSliceCost / sliceLevelEntries.length;
const projectedRemaining = avgCostPerSlice * data.remainingSliceCount;
lines.push(` Avg cost/slice: ${th.fg("text", formatCost(avgCostPerSlice))}`);
lines.push(
` Projected remaining: ${th.fg("text", formatCost(projectedRemaining))} ` +
`(${formatCost(avgCostPerSlice)}/slice × ${data.remainingSliceCount} remaining)`,
);
// Burn rate
if (data.totals.duration > 0) {
const costPerHour = data.totals.cost / (data.totals.duration / 3_600_000);
lines.push(` Burn rate: ${th.fg("text", formatCost(costPerHour) + "/hr")}`);
}
// Sparkline of per-slice costs
const sliceCosts = sliceLevelEntries.map(s => s.cost);
if (sliceCosts.length > 0) {
const spark = sparkline(sliceCosts);
lines.push(` Cost trend: ${spark}`);
}
// Budget warning: projected total > 2× current spend
const projectedTotal = data.totals.cost + projectedRemaining;
if (projectedTotal > 2 * data.totals.cost && data.remainingSliceCount > 0) {
lines.push(th.fg("warning", ` ⚠ Projected total ${formatCost(projectedTotal)} exceeds 2× current spend`));
}
return lines;
}
// ─── Timeline View ──────────────────────────────────────────────────────────
// ─── Timeline View (Gantt) ──────────────────────────────────────────────────
export function renderTimelineView(
data: VisualizerData,
@ -251,6 +471,17 @@ export function renderTimelineView(
return lines;
}
// Gantt mode for wide terminals, list mode for narrow
if (width >= 90) {
return renderGanttView(data, th, width);
}
return renderTimelineList(data, th, width);
}
function renderTimelineList(data: VisualizerData, th: Theme, width: number): string[] {
const lines: string[] = [];
// Show up to 20 most recent (units are sorted by startedAt asc, show most recent)
const recent = data.units.slice(-20).reverse();
@ -291,3 +522,234 @@ export function renderTimelineView(
return lines;
}
function renderGanttView(data: VisualizerData, th: Theme, width: number): string[] {
const lines: string[] = [];
const recent = data.units.slice(-20);
if (recent.length === 0) return lines;
const finishedUnits = recent.filter(u => u.finishedAt > 0);
if (finishedUnits.length === 0) return renderTimelineList(data, th, width);
const minStart = Math.min(...recent.map(u => u.startedAt));
const maxEnd = Math.max(...recent.map(u => u.finishedAt > 0 ? u.finishedAt : Date.now()));
const totalSpan = maxEnd - minStart;
if (totalSpan <= 0) return renderTimelineList(data, th, width);
const gutterWidth = 20;
const barArea = Math.max(10, width - gutterWidth - 25);
// Time axis labels
const startLabel = formatTimeLabel(minStart);
const endLabel = formatTimeLabel(maxEnd);
lines.push(
`${" ".repeat(gutterWidth)} ${th.fg("dim", startLabel)}` +
`${" ".repeat(Math.max(1, barArea - startLabel.length - endLabel.length))}` +
`${th.fg("dim", endLabel)}`,
);
// Phase tracking for separators
let lastPhase = "";
for (const unit of recent) {
const phase = classifyUnitPhase(unit.type);
if (phase !== lastPhase && lastPhase !== "") {
lines.push(th.fg("dim", " " + "─".repeat(width - 4)));
}
lastPhase = phase;
const end = unit.finishedAt > 0 ? unit.finishedAt : Date.now();
const startPos = Math.round(((unit.startedAt - minStart) / totalSpan) * barArea);
const endPos = Math.round(((end - minStart) / totalSpan) * barArea);
const barLen = Math.max(1, endPos - startPos);
const phaseColor =
phase === "research" ? "dim" :
phase === "planning" ? "accent" :
phase === "execution" ? "success" :
"warning";
const barStr =
" ".repeat(startPos) +
th.fg(phaseColor, "█".repeat(barLen)) +
" ".repeat(Math.max(0, barArea - startPos - barLen));
const gutter = padRight(
truncateToWidth(`${unit.type.slice(0, 8)} ${unit.id}`, gutterWidth - 1),
gutterWidth,
);
const duration = end - unit.startedAt;
const durStr = formatDuration(duration);
const costStr = formatCost(unit.cost);
lines.push(truncateToWidth(`${gutter}${barStr} ${durStr} ${costStr}`, width));
}
return lines;
}
function formatTimeLabel(ts: number): string {
const dt = new Date(ts);
return `${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
}
// ─── Agent View ──────────────────────────────────────────────────────────────
export function renderAgentView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
const activity = data.agentActivity;
if (!activity) {
lines.push(th.fg("dim", "No agent activity data."));
return lines;
}
// Status line
const statusDot = activity.active
? th.fg("success", "●")
: th.fg("dim", "○");
const statusText = activity.active ? "ACTIVE" : "IDLE";
const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "—";
lines.push(
joinColumns(
`Status: ${statusDot} ${statusText}`,
`Elapsed: ${elapsedStr}`,
width,
),
);
if (activity.currentUnit) {
lines.push(`Current: ${th.fg("accent", `${activity.currentUnit.type} ${activity.currentUnit.id}`)}`);
} else {
lines.push(th.fg("dim", "Not in auto mode"));
}
lines.push("");
// Progress bar
const completed = activity.completedUnits;
const total = Math.max(completed, activity.totalSlices);
if (total > 0) {
const pct = Math.min(1, completed / total);
const barW = Math.max(10, Math.min(30, width - 30));
const fillLen = Math.round(pct * barW);
const bar =
th.fg("accent", "█".repeat(fillLen)) +
th.fg("dim", "░".repeat(barW - fillLen));
lines.push(`Progress ${bar} ${completed}/${total} slices`);
}
// Rate and session stats
const rateStr = activity.completionRate > 0
? `${activity.completionRate.toFixed(1)} units/hr`
: "—";
lines.push(
`Rate: ${th.fg("text", rateStr)} ` +
`Session: ${th.fg("text", formatCost(activity.sessionCost))} ` +
`${th.fg("text", formatTokenCount(activity.sessionTokens))} tokens`,
);
lines.push("");
// Recent completed units (last 5)
const recentUnits = data.units.filter(u => u.finishedAt > 0).slice(-5).reverse();
if (recentUnits.length > 0) {
lines.push(th.fg("accent", th.bold("Recent (last 5):")));
for (const u of recentUnits) {
const dt = new Date(u.startedAt);
const hh = String(dt.getHours()).padStart(2, "0");
const mm = String(dt.getMinutes()).padStart(2, "0");
const dur = formatDuration(u.finishedAt - u.startedAt);
const cost = formatCost(u.cost);
const typeLabel = padRight(u.type, 16);
lines.push(
truncateToWidth(
` ${hh}:${mm} ${th.fg("success", "✓")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`,
width,
),
);
}
} else {
lines.push(th.fg("dim", "No completed units yet."));
}
return lines;
}
// ─── Changelog View ──────────────────────────────────────────────────────────
export function renderChangelogView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
const changelog = data.changelog;
if (changelog.entries.length === 0) {
lines.push(th.fg("dim", "No completed slices yet."));
return lines;
}
lines.push(th.fg("accent", th.bold("Changes")));
lines.push("");
for (const entry of changelog.entries) {
const header = `${entry.milestoneId}/${entry.sliceId}: ${entry.title}`;
lines.push(th.fg("success", header));
if (entry.oneLiner) {
lines.push(` "${th.fg("text", entry.oneLiner)}"`);
}
if (entry.filesModified.length > 0) {
lines.push(" Files:");
for (const f of entry.filesModified) {
lines.push(
truncateToWidth(
` ${th.fg("success", "✓")} ${f.path}${f.description}`,
width,
),
);
}
}
if (entry.completedAt) {
lines.push(th.fg("dim", ` Completed: ${entry.completedAt}`));
}
lines.push("");
}
return lines;
}
// ─── Export View ─────────────────────────────────────────────────────────────
export function renderExportView(
_data: VisualizerData,
th: Theme,
_width: number,
lastExportPath?: string,
): string[] {
const lines: string[] = [];
lines.push(th.fg("accent", th.bold("Export Options")));
lines.push("");
lines.push(` ${th.fg("accent", "[m]")} Markdown report — full project summary with tables`);
lines.push(` ${th.fg("accent", "[j]")} JSON report — machine-readable project data`);
lines.push(` ${th.fg("accent", "[s]")} Snapshot — current view as plain text`);
if (lastExportPath) {
lines.push("");
lines.push(th.fg("dim", `Last export: ${lastExportPath}`));
}
return lines;
}