feat: add workflow visualizer TUI overlay with 4-tab interactive view (#626)

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

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

Modified:
- commands.ts: register "visualize" subcommand + handler
- auto.ts: milestone completion hint when auto_visualize enabled
- preferences.ts: add auto_visualize preference key
This commit is contained in:
Flux Labs 2026-03-16 09:19:08 -05:00 committed by GitHub
parent 88bdf9bc8d
commit 5ade4bf3ed
8 changed files with 1131 additions and 3 deletions

View file

@ -1433,6 +1433,11 @@ async function dispatchNextUnit(
"info",
);
sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
// Hint: visualizer available after milestone transition
const vizPrefs = loadEffectiveGSDPreferences()?.preferences;
if (vizPrefs?.auto_visualize) {
ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
}
// Reset stuck detection for new milestone
unitDispatchCount.clear();
unitRecoveryCount.clear();

View file

@ -11,6 +11,7 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
import { GSDVisualizerOverlay } from "./visualizer-overlay.js";
import { showQueue, showDiscuss } from "./guided-flow.js";
import { startAuto, stopAuto, pauseAuto, isAutoActive, isAutoPaused, isStepMode, stopAutoRemote } from "./auto.js";
import { resolveProjectRoot } from "./worktree.js";
@ -65,10 +66,10 @@ function projectRoot(): string {
export function registerGSDCommand(pi: ExtensionAPI): void {
pi.registerCommand("gsd", {
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
description: "GSD — Get Shit Done: /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|history|undo|skip|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer|knowledge",
getArgumentCompletions: (prefix: string) => {
const subcommands = [
"next", "auto", "stop", "pause", "status", "queue", "discuss",
"next", "auto", "stop", "pause", "status", "visualize", "queue", "discuss",
"capture", "triage",
"history", "undo", "skip", "export", "cleanup", "prefs",
"config", "hooks", "doctor", "migrate", "remote", "steer", "knowledge",
@ -165,6 +166,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
return;
}
if (trimmed === "visualize") {
await handleVisualize(ctx);
return;
}
if (trimmed === "prefs" || trimmed.startsWith("prefs ")) {
await handlePrefs(trimmed.replace(/^prefs\s*/, "").trim(), ctx);
return;
@ -318,7 +324,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
}
ctx.ui.notify(
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|queue|capture|triage|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
`Unknown: /gsd ${trimmed}. Use /gsd next|auto|stop|pause|status|visualize|queue|capture|triage|discuss|history|undo|skip <unit>|export|cleanup|prefs|config|hooks|doctor|migrate|remote|steer <change>|knowledge <type> <entry>.`,
"warning",
);
},
@ -356,6 +362,28 @@ export async function fireStatusViaCommand(
await handleStatus(ctx as ExtensionCommandContext);
}
async function handleVisualize(ctx: ExtensionCommandContext): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify("Visualizer requires an interactive terminal.", "warning");
return;
}
await ctx.ui.custom<void>(
(tui, theme, _kb, done) => {
return new GSDVisualizerOverlay(tui, theme, () => done());
},
{
overlay: true,
overlayOptions: {
width: "80%",
minWidth: 80,
maxHeight: "90%",
anchor: "center",
},
},
);
}
async function handlePrefs(args: string, ctx: ExtensionCommandContext): Promise<void> {
const trimmed = args.trim();

View file

@ -41,6 +41,7 @@ const KNOWN_PREFERENCE_KEYS = new Set<string>([
"dynamic_routing",
"token_profile",
"phases",
"auto_visualize",
]);
export interface GSDSkillRule {
@ -134,6 +135,7 @@ export interface GSDPreferences {
dynamic_routing?: DynamicRoutingConfig;
token_profile?: TokenProfile;
phases?: PhaseSkipPreferences;
auto_visualize?: boolean;
}
export interface LoadedGSDPreferences {

View file

@ -0,0 +1,198 @@
// Tests for GSD visualizer data loader.
// Verifies the VisualizerData interface shape and source-file contracts.
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createTestContext } from "./test-helpers.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
const { assertTrue, report } = createTestContext();
const dataPath = join(__dirname, "..", "visualizer-data.ts");
const dataSrc = readFileSync(dataPath, "utf-8");
console.log("\n=== visualizer-data.ts source contracts ===");
// Interface exports
assertTrue(
dataSrc.includes("export interface VisualizerData"),
"exports VisualizerData interface",
);
assertTrue(
dataSrc.includes("export interface VisualizerMilestone"),
"exports VisualizerMilestone interface",
);
assertTrue(
dataSrc.includes("export interface VisualizerSlice"),
"exports VisualizerSlice interface",
);
assertTrue(
dataSrc.includes("export interface VisualizerTask"),
"exports VisualizerTask interface",
);
// Function export
assertTrue(
dataSrc.includes("export async function loadVisualizerData"),
"exports loadVisualizerData function",
);
// Data source usage
assertTrue(
dataSrc.includes("deriveState"),
"uses deriveState for state derivation",
);
assertTrue(
dataSrc.includes("findMilestoneIds"),
"uses findMilestoneIds to enumerate milestones",
);
assertTrue(
dataSrc.includes("parseRoadmap"),
"uses parseRoadmap for roadmap parsing",
);
assertTrue(
dataSrc.includes("parsePlan"),
"uses parsePlan for plan parsing",
);
assertTrue(
dataSrc.includes("getLedger"),
"uses getLedger for in-memory metrics",
);
assertTrue(
dataSrc.includes("loadLedgerFromDisk"),
"uses loadLedgerFromDisk as fallback",
);
assertTrue(
dataSrc.includes("getProjectTotals"),
"uses getProjectTotals for aggregation",
);
assertTrue(
dataSrc.includes("aggregateByPhase"),
"uses aggregateByPhase",
);
assertTrue(
dataSrc.includes("aggregateBySlice"),
"uses aggregateBySlice",
);
assertTrue(
dataSrc.includes("aggregateByModel"),
"uses aggregateByModel",
);
// Interface fields
assertTrue(
dataSrc.includes("dependsOn: string[]"),
"VisualizerMilestone has dependsOn field",
);
assertTrue(
dataSrc.includes("depends: string[]"),
"VisualizerSlice has depends field",
);
assertTrue(
dataSrc.includes("totals: ProjectTotals | null"),
"VisualizerData has nullable totals",
);
assertTrue(
dataSrc.includes("units: UnitMetrics[]"),
"VisualizerData has units array",
);
// Verify overlay source exists and imports data module
const overlayPath = join(__dirname, "..", "visualizer-overlay.ts");
const overlaySrc = readFileSync(overlayPath, "utf-8");
console.log("\n=== visualizer-overlay.ts source contracts ===");
assertTrue(
overlaySrc.includes("export class GSDVisualizerOverlay"),
"exports GSDVisualizerOverlay class",
);
assertTrue(
overlaySrc.includes("loadVisualizerData"),
"overlay uses loadVisualizerData",
);
assertTrue(
overlaySrc.includes("renderProgressView"),
"overlay delegates to renderProgressView",
);
assertTrue(
overlaySrc.includes("renderDepsView"),
"overlay delegates to renderDepsView",
);
assertTrue(
overlaySrc.includes("renderMetricsView"),
"overlay delegates to renderMetricsView",
);
assertTrue(
overlaySrc.includes("renderTimelineView"),
"overlay delegates to renderTimelineView",
);
assertTrue(
overlaySrc.includes("handleInput"),
"overlay has handleInput method",
);
assertTrue(
overlaySrc.includes("dispose"),
"overlay has dispose method",
);
assertTrue(
overlaySrc.includes("wrapInBox"),
"overlay has wrapInBox helper",
);
assertTrue(
overlaySrc.includes("activeTab"),
"overlay tracks active tab",
);
assertTrue(
overlaySrc.includes("scrollOffsets"),
"overlay tracks per-tab scroll offsets",
);
// Verify commands.ts integration
const commandsPath = join(__dirname, "..", "commands.ts");
const commandsSrc = readFileSync(commandsPath, "utf-8");
console.log("\n=== commands.ts integration ===");
assertTrue(
commandsSrc.includes('"visualize"'),
"commands.ts has visualize in subcommands array",
);
assertTrue(
commandsSrc.includes("GSDVisualizerOverlay"),
"commands.ts imports GSDVisualizerOverlay",
);
assertTrue(
commandsSrc.includes("handleVisualize"),
"commands.ts has handleVisualize handler",
);
report();

View file

@ -0,0 +1,255 @@
// Tests for GSD visualizer view renderers.
// Tests the pure view functions with mock data — no file I/O.
import {
renderProgressView,
renderDepsView,
renderMetricsView,
renderTimelineView,
} from "../visualizer-views.js";
import type { VisualizerData } from "../visualizer-data.js";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
// ─── Mock theme ─────────────────────────────────────────────────────────────
const mockTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
} as any;
// ─── Test data factories ────────────────────────────────────────────────────
function makeVisualizerData(overrides: Partial<VisualizerData> = {}): VisualizerData {
return {
milestones: [],
phase: "executing",
totals: null,
byPhase: [],
bySlice: [],
byModel: [],
units: [],
...overrides,
};
}
// ─── renderProgressView ─────────────────────────────────────────────────────
console.log("\n=== renderProgressView ===");
{
const data = makeVisualizerData({
milestones: [
{
id: "M001",
title: "First Milestone",
status: "active",
dependsOn: [],
slices: [
{
id: "S01",
title: "Core Types",
done: true,
active: false,
risk: "low",
depends: [],
tasks: [],
},
{
id: "S02",
title: "State Engine",
done: false,
active: true,
risk: "high",
depends: ["S01"],
tasks: [
{ id: "T01", title: "Dispatch Loop", done: false, active: true },
{ id: "T02", title: "Session Mgmt", done: true, active: false },
],
},
{
id: "S03",
title: "Dashboard",
done: false,
active: false,
risk: "medium",
depends: ["S02"],
tasks: [],
},
],
},
{
id: "M002",
title: "Plugin Arch",
status: "pending",
dependsOn: ["M001"],
slices: [],
},
],
});
const lines = renderProgressView(data, mockTheme, 80);
assertTrue(lines.length > 0, "progress view produces output");
assertTrue(lines.some(l => l.includes("M001")), "shows milestone M001");
assertTrue(lines.some(l => l.includes("S01")), "shows slice S01");
assertTrue(lines.some(l => l.includes("T01")), "shows task T01 for active slice");
assertTrue(lines.some(l => l.includes("M002")), "shows milestone M002");
assertTrue(lines.some(l => l.includes("depends on M001")), "shows dependency note");
}
{
const data = makeVisualizerData({ milestones: [] });
const lines = renderProgressView(data, mockTheme, 80);
assertEq(lines.length, 0, "empty milestones produce no lines");
}
// ─── renderDepsView ─────────────────────────────────────────────────────────
console.log("\n=== renderDepsView ===");
{
const data = makeVisualizerData({
milestones: [
{
id: "M001",
title: "First",
status: "active",
dependsOn: [],
slices: [
{ id: "S01", title: "A", done: false, active: true, risk: "low", depends: [], tasks: [] },
{ id: "S02", title: "B", done: false, active: false, risk: "low", depends: ["S01"], tasks: [] },
],
},
{
id: "M002",
title: "Second",
status: "pending",
dependsOn: ["M001"],
slices: [],
},
],
});
const lines = renderDepsView(data, mockTheme, 80);
assertTrue(lines.length > 0, "deps view produces output");
assertTrue(lines.some(l => l.includes("M001") && l.includes("M002")), "shows milestone dep edge");
assertTrue(lines.some(l => l.includes("S01") && l.includes("S02")), "shows slice dep edge");
}
{
const data = makeVisualizerData({
milestones: [
{ id: "M001", title: "Only", status: "active", dependsOn: [], slices: [] },
],
});
const lines = renderDepsView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("No milestone dependencies")), "shows no-deps message");
}
// ─── renderMetricsView ──────────────────────────────────────────────────────
console.log("\n=== renderMetricsView ===");
{
const data = makeVisualizerData({
totals: {
units: 5,
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 },
cost: 2.50,
duration: 60000,
toolCalls: 15,
assistantMessages: 10,
userMessages: 5,
},
byPhase: [
{
phase: "execution",
units: 3,
tokens: { input: 600, output: 300, cacheRead: 100, cacheWrite: 50, total: 1050 },
cost: 1.50,
duration: 40000,
},
{
phase: "planning",
units: 2,
tokens: { input: 400, output: 200, cacheRead: 100, cacheWrite: 50, total: 750 },
cost: 1.00,
duration: 20000,
},
],
byModel: [
{
model: "claude-opus-4-6",
units: 5,
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100, total: 1800 },
cost: 2.50,
},
],
});
const lines = renderMetricsView(data, mockTheme, 80);
assertTrue(lines.length > 0, "metrics view produces output");
assertTrue(lines.some(l => l.includes("$2.50")), "shows total cost");
assertTrue(lines.some(l => l.includes("execution")), "shows phase name");
assertTrue(lines.some(l => l.includes("claude-opus-4-6")), "shows model name");
}
{
const data = makeVisualizerData({ totals: null });
const lines = renderMetricsView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("No metrics data")), "shows no-data message");
}
// ─── renderTimelineView ─────────────────────────────────────────────────────
console.log("\n=== renderTimelineView ===");
{
const now = Date.now();
const data = makeVisualizerData({
units: [
{
type: "execute-task",
id: "M001/S01/T01",
model: "claude-opus-4-6",
startedAt: now - 120000,
finishedAt: now - 60000,
tokens: { input: 500, output: 200, cacheRead: 100, cacheWrite: 50, total: 850 },
cost: 0.42,
toolCalls: 5,
assistantMessages: 3,
userMessages: 1,
},
{
type: "plan-slice",
id: "M001/S02",
model: "claude-opus-4-6",
startedAt: now - 60000,
finishedAt: now - 30000,
tokens: { input: 300, output: 150, cacheRead: 50, cacheWrite: 25, total: 525 },
cost: 0.18,
toolCalls: 2,
assistantMessages: 2,
userMessages: 1,
},
],
});
const lines = renderTimelineView(data, mockTheme, 80);
assertTrue(lines.length >= 2, "timeline view produces lines for each unit");
assertTrue(lines.some(l => l.includes("execute-task")), "shows unit type");
assertTrue(lines.some(l => l.includes("M001/S01/T01")), "shows unit id");
assertTrue(lines.some(l => l.includes("$0.42")), "shows unit cost");
}
{
const data = makeVisualizerData({ units: [] });
const lines = renderTimelineView(data, mockTheme, 80);
assertTrue(lines.some(l => l.includes("No execution history")), "shows empty message");
}
// ─── Report ─────────────────────────────────────────────────────────────────
report();

View file

@ -0,0 +1,154 @@
// Data loader for workflow visualizer overlay — aggregates state + metrics.
import { deriveState } from './state.js';
import { parseRoadmap, parsePlan, loadFile } from './files.js';
import { findMilestoneIds } from './guided-flow.js';
import { resolveMilestoneFile, resolveSliceFile } from './paths.js';
import {
getLedger,
getProjectTotals,
aggregateByPhase,
aggregateBySlice,
aggregateByModel,
loadLedgerFromDisk,
} from './metrics.js';
import type { Phase } from './types.js';
import type {
ProjectTotals,
PhaseAggregate,
SliceAggregate,
ModelAggregate,
UnitMetrics,
} from './metrics.js';
// ─── Visualizer Types ─────────────────────────────────────────────────────────
export interface VisualizerMilestone {
id: string;
title: string;
status: 'complete' | 'active' | 'pending';
dependsOn: string[];
slices: VisualizerSlice[];
}
export interface VisualizerSlice {
id: string;
title: string;
done: boolean;
active: boolean;
risk: string;
depends: string[];
tasks: VisualizerTask[];
}
export interface VisualizerTask {
id: string;
title: string;
done: boolean;
active: boolean;
}
export interface VisualizerData {
milestones: VisualizerMilestone[];
phase: Phase;
totals: ProjectTotals | null;
byPhase: PhaseAggregate[];
bySlice: SliceAggregate[];
byModel: ModelAggregate[];
units: UnitMetrics[];
}
// ─── Loader ───────────────────────────────────────────────────────────────────
export async function loadVisualizerData(basePath: string): Promise<VisualizerData> {
const state = await deriveState(basePath);
const milestoneIds = findMilestoneIds(basePath);
const milestones: VisualizerMilestone[] = [];
for (const mid of milestoneIds) {
const entry = state.registry.find(r => r.id === mid);
const status = entry?.status ?? 'pending';
const dependsOn = entry?.dependsOn ?? [];
const slices: VisualizerSlice[] = [];
const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP');
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
if (roadmapContent) {
const roadmap = parseRoadmap(roadmapContent);
for (const s of roadmap.slices) {
const isActiveSlice =
state.activeMilestone?.id === mid &&
state.activeSlice?.id === s.id;
const tasks: VisualizerTask[] = [];
if (isActiveSlice) {
const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
const planContent = planFile ? await loadFile(planFile) : null;
if (planContent) {
const plan = parsePlan(planContent);
for (const t of plan.tasks) {
tasks.push({
id: t.id,
title: t.title,
done: t.done,
active: state.activeTask?.id === t.id,
});
}
}
}
slices.push({
id: s.id,
title: s.title,
done: s.done,
active: isActiveSlice,
risk: s.risk,
depends: s.depends,
tasks,
});
}
}
milestones.push({
id: mid,
title: entry?.title ?? mid,
status,
dependsOn,
slices,
});
}
// Metrics
let totals: ProjectTotals | null = null;
let byPhase: PhaseAggregate[] = [];
let bySlice: SliceAggregate[] = [];
let byModel: ModelAggregate[] = [];
let units: UnitMetrics[] = [];
const ledger = getLedger() ?? loadLedgerFromDisk(basePath);
if (ledger && ledger.units.length > 0) {
units = [...ledger.units].sort((a, b) => a.startedAt - b.startedAt);
totals = getProjectTotals(units);
byPhase = aggregateByPhase(units);
bySlice = aggregateBySlice(units);
byModel = aggregateByModel(units);
}
return {
milestones,
phase: state.phase,
totals,
byPhase,
bySlice,
byModel,
units,
};
}

View file

@ -0,0 +1,193 @@
import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
import { loadVisualizerData, type VisualizerData } from "./visualizer-data.js";
import {
renderProgressView,
renderDepsView,
renderMetricsView,
renderTimelineView,
} from "./visualizer-views.js";
const TAB_LABELS = ["1 Progress", "2 Deps", "3 Metrics", "4 Timeline"];
export class GSDVisualizerOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
private onClose: () => void;
activeTab = 0;
scrollOffsets: number[] = [0, 0, 0, 0];
loading = true;
disposed = false;
cachedWidth?: number;
cachedLines?: string[];
refreshTimer: ReturnType<typeof setInterval>;
data: VisualizerData | null = null;
basePath: string;
constructor(
tui: { requestRender: () => void },
theme: Theme,
onClose: () => void,
) {
this.tui = tui;
this.theme = theme;
this.onClose = onClose;
this.basePath = process.cwd();
loadVisualizerData(this.basePath).then((d) => {
this.data = d;
this.loading = false;
this.tui.requestRender();
});
this.refreshTimer = setInterval(() => {
loadVisualizerData(this.basePath).then((d) => {
if (this.disposed) return;
this.data = d;
this.invalidate();
this.tui.requestRender();
});
}, 2000);
}
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
this.dispose();
this.onClose();
return;
}
if (matchesKey(data, Key.tab)) {
this.activeTab = (this.activeTab + 1) % 4;
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "1" || data === "2" || data === "3" || data === "4") {
this.activeTab = parseInt(data, 10) - 1;
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
this.scrollOffsets[this.activeTab]++;
this.invalidate();
this.tui.requestRender();
return;
}
if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - 1);
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "g") {
this.scrollOffsets[this.activeTab] = 0;
this.invalidate();
this.tui.requestRender();
return;
}
if (data === "G") {
this.scrollOffsets[this.activeTab] = 999;
this.invalidate();
this.tui.requestRender();
return;
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const th = this.theme;
const innerWidth = width - 4;
const content: string[] = [];
// Tab bar
const tabs = TAB_LABELS.map((label, i) => {
if (i === this.activeTab) {
return th.fg("accent", `[${label}]`);
}
return th.fg("dim", `[${label}]`);
});
content.push(" " + tabs.join(" "));
content.push("");
if (this.loading) {
const loadingText = "Loading…";
const vis = visibleWidth(loadingText);
const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2));
content.push(" ".repeat(leftPad) + loadingText);
} else if (this.data) {
let viewLines: string[] = [];
switch (this.activeTab) {
case 0:
viewLines = renderProgressView(this.data, th, innerWidth);
break;
case 1:
viewLines = renderDepsView(this.data, th, innerWidth);
break;
case 2:
viewLines = renderMetricsView(this.data, th, innerWidth);
break;
case 3:
viewLines = renderTimelineView(this.data, th, innerWidth);
break;
}
content.push(...viewLines);
}
// Apply scroll
const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24);
const chromeHeight = 2;
const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
const maxScroll = Math.max(0, content.length - visibleContentRows);
this.scrollOffsets[this.activeTab] = Math.min(this.scrollOffsets[this.activeTab], maxScroll);
const offset = this.scrollOffsets[this.activeTab];
const visibleContent = content.slice(offset, offset + visibleContentRows);
const lines = this.wrapInBox(visibleContent, width);
// Footer hint
const hint = th.fg("dim", "Tab/1-4 switch · ↑↓ scroll · g/G top/end · esc close");
const hintVis = visibleWidth(hint);
const hintPad = Math.max(0, Math.floor((width - hintVis) / 2));
lines.push(" ".repeat(hintPad) + hint);
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
private wrapInBox(inner: string[], width: number): string[] {
const th = this.theme;
const border = (s: string) => th.fg("borderAccent", s);
const innerWidth = width - 4;
const lines: string[] = [];
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
for (const line of inner) {
const truncated = truncateToWidth(line, innerWidth);
const padWidth = Math.max(0, innerWidth - visibleWidth(truncated));
lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│"));
}
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
dispose(): void {
this.disposed = true;
clearInterval(this.refreshTimer);
}
}

View file

@ -0,0 +1,293 @@
// View renderers for the GSD workflow visualizer overlay.
import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import type { VisualizerData, VisualizerMilestone } from "./visualizer-data.js";
import { formatCost, formatTokenCount } from "./metrics.js";
// ─── Local Helpers ───────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
// ─── Progress View ───────────────────────────────────────────────────────────
export function renderProgressView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
for (const ms of data.milestones) {
// Milestone header line
const statusGlyph =
ms.status === "complete"
? th.fg("success", "✓")
: ms.status === "active"
? th.fg("accent", "▸")
: th.fg("dim", "○");
const statusLabel =
ms.status === "complete"
? th.fg("success", "complete")
: ms.status === "active"
? th.fg("accent", "active")
: th.fg("dim", "pending");
const msLeft = `${ms.id}: ${ms.title}`;
const msRight = `${statusGlyph} ${statusLabel}`;
lines.push(joinColumns(msLeft, msRight, width));
if (ms.slices.length === 0 && ms.dependsOn.length > 0) {
lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
continue;
}
if (ms.status === "pending" && ms.dependsOn.length > 0) {
lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
continue;
}
for (const sl of ms.slices) {
// Slice line
const slGlyph = sl.done
? th.fg("success", "✓")
: sl.active
? th.fg("accent", "▸")
: th.fg("dim", "○");
const riskColor =
sl.risk === "high"
? "warning"
: sl.risk === "medium"
? "text"
: "dim";
const riskBadge = th.fg(riskColor, sl.risk);
const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}`;
lines.push(joinColumns(slLeft, riskBadge, width));
// Show tasks for active slice
if (sl.active && sl.tasks.length > 0) {
for (const task of sl.tasks) {
const tGlyph = task.done
? th.fg("success", "✓")
: task.active
? th.fg("accent", "▸")
: th.fg("dim", "○");
lines.push(` ${tGlyph} ${task.id}: ${task.title}`);
}
}
}
}
return lines;
}
// ─── Dependencies View ───────────────────────────────────────────────────────
export function renderDepsView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
// Milestone Dependencies
lines.push(th.fg("accent", th.bold("Milestone Dependencies")));
lines.push("");
const msDeps = data.milestones.filter((ms) => ms.dependsOn.length > 0);
if (msDeps.length === 0) {
lines.push(th.fg("dim", " No milestone dependencies."));
} else {
for (const ms of msDeps) {
for (const dep of ms.dependsOn) {
lines.push(
` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", ms.id)}`,
);
}
}
}
lines.push("");
// Slice Dependencies (active milestone)
lines.push(th.fg("accent", th.bold("Slice Dependencies (active milestone)")));
lines.push("");
const activeMs = data.milestones.find((ms) => ms.status === "active");
if (!activeMs) {
lines.push(th.fg("dim", " No active milestone."));
} else {
const slDeps = activeMs.slices.filter((sl) => sl.depends.length > 0);
if (slDeps.length === 0) {
lines.push(th.fg("dim", " No slice dependencies."));
} else {
for (const sl of slDeps) {
for (const dep of sl.depends) {
lines.push(
` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", sl.id)}`,
);
}
}
}
}
return lines;
}
// ─── Metrics View ────────────────────────────────────────────────────────────
export function renderMetricsView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
if (data.totals === null) {
lines.push(th.fg("dim", "No metrics data available."));
return lines;
}
const totals = data.totals;
// Summary line
lines.push(
th.fg("accent", th.bold("Summary")),
);
lines.push(
` Cost: ${th.fg("text", formatCost(totals.cost))} ` +
`Tokens: ${th.fg("text", formatTokenCount(totals.tokens.total))} ` +
`Units: ${th.fg("text", String(totals.units))}`,
);
lines.push("");
const barWidth = Math.max(10, width - 40);
// By Phase
if (data.byPhase.length > 0) {
lines.push(th.fg("accent", th.bold("By Phase")));
lines.push("");
const maxPhaseCost = Math.max(...data.byPhase.map((p) => p.cost));
for (const phase of data.byPhase) {
const pct = totals.cost > 0 ? (phase.cost / totals.cost) * 100 : 0;
const fillLen =
maxPhaseCost > 0
? Math.round((phase.cost / maxPhaseCost) * barWidth)
: 0;
const bar =
th.fg("accent", "█".repeat(fillLen)) +
th.fg("dim", "░".repeat(barWidth - fillLen));
const label = padRight(phase.phase, 14);
const costStr = formatCost(phase.cost);
const pctStr = `${pct.toFixed(1)}%`;
const tokenStr = formatTokenCount(phase.tokens.total);
lines.push(` ${label} ${bar} ${costStr} ${pctStr} ${tokenStr}`);
}
lines.push("");
}
// By Model
if (data.byModel.length > 0) {
lines.push(th.fg("accent", th.bold("By Model")));
lines.push("");
const maxModelCost = Math.max(...data.byModel.map((m) => m.cost));
for (const model of data.byModel) {
const pct = totals.cost > 0 ? (model.cost / totals.cost) * 100 : 0;
const fillLen =
maxModelCost > 0
? Math.round((model.cost / maxModelCost) * barWidth)
: 0;
const bar =
th.fg("accent", "█".repeat(fillLen)) +
th.fg("dim", "░".repeat(barWidth - fillLen));
const label = padRight(model.model, 20);
const costStr = formatCost(model.cost);
const pctStr = `${pct.toFixed(1)}%`;
lines.push(` ${label} ${bar} ${costStr} ${pctStr}`);
}
}
return lines;
}
// ─── Timeline View ──────────────────────────────────────────────────────────
export function renderTimelineView(
data: VisualizerData,
th: Theme,
width: number,
): string[] {
const lines: string[] = [];
if (data.units.length === 0) {
lines.push(th.fg("dim", "No execution history."));
return lines;
}
// Show up to 20 most recent (units are sorted by startedAt asc, show most recent)
const recent = data.units.slice(-20).reverse();
const maxDuration = Math.max(
...recent.map((u) => u.finishedAt - u.startedAt),
);
const timeBarWidth = Math.max(4, Math.min(12, width - 60));
for (const unit of recent) {
const dt = new Date(unit.startedAt);
const hh = String(dt.getHours()).padStart(2, "0");
const mm = String(dt.getMinutes()).padStart(2, "0");
const time = `${hh}:${mm}`;
const duration = unit.finishedAt - unit.startedAt;
const glyph =
unit.finishedAt > 0
? th.fg("success", "✓")
: th.fg("accent", "▸");
const typeLabel = padRight(unit.type, 16);
const idLabel = padRight(unit.id, 14);
const fillLen =
maxDuration > 0
? Math.round((duration / maxDuration) * timeBarWidth)
: 0;
const bar =
th.fg("accent", "█".repeat(fillLen)) +
th.fg("dim", "░".repeat(timeBarWidth - fillLen));
const durStr = formatDuration(duration);
const costStr = formatCost(unit.cost);
const line = ` ${time} ${glyph} ${typeLabel} ${idLabel} ${bar} ${durStr} ${costStr}`;
lines.push(truncateToWidth(line, width));
}
return lines;
}