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:
parent
88bdf9bc8d
commit
5ade4bf3ed
8 changed files with 1131 additions and 3 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
198
src/resources/extensions/gsd/tests/visualizer-data.test.ts
Normal file
198
src/resources/extensions/gsd/tests/visualizer-data.test.ts
Normal 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();
|
||||
255
src/resources/extensions/gsd/tests/visualizer-views.test.ts
Normal file
255
src/resources/extensions/gsd/tests/visualizer-views.test.ts
Normal 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();
|
||||
154
src/resources/extensions/gsd/visualizer-data.ts
Normal file
154
src/resources/extensions/gsd/visualizer-data.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
193
src/resources/extensions/gsd/visualizer-overlay.ts
Normal file
193
src/resources/extensions/gsd/visualizer-overlay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
293
src/resources/extensions/gsd/visualizer-views.ts
Normal file
293
src/resources/extensions/gsd/visualizer-views.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue