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:
parent
75e82a4236
commit
ee14135d6c
9 changed files with 1648 additions and 33 deletions
|
|
@ -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]",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
120
src/resources/extensions/gsd/tests/visualizer-overlay.test.ts
Normal file
120
src/resources/extensions/gsd/tests/visualizer-overlay.test.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue