singularity-forge/scripts/preview-dashboard.ts
Jeremy McSpadden c9d79a829c feat(dashboard): two-column layout with redesigned widget (#1530)
* feat(dashboard): two-column layout with redesigned widget

- Two-column layout: progress bar left, task checklist right
- 4 widget modes: full → small → min → off (cycle with /gsd widget)
- Health indicator and ETA in header line for immediate visibility
- Simplified stats: 3 items (hit rate, cost, context %) instead of 7
- Short PWD (last 2 segments), git worktree name with ⎇ prefix
- Last commit time + message in footer (cached every 15s)
- Preview script with mock data for all modes

* docs: add dashboard widget screenshots for PR #1530

* docs: update dashboard screenshots with wider renders

* docs: wider full-width dashboard screenshots

* feat(dashboard): persist widget_mode in preferences

- Add widget_mode to GSDPreferences and KNOWN_PREFERENCE_KEYS
- Load saved widget_mode from preferences on first access
- Persist to global PREFERENCES.md on /gsd widget change
- Default remains "full" when no preference is set
2026-03-19 20:07:18 -06:00

285 lines
11 KiB
TypeScript

/**
* Visual preview of the auto-mode dashboard widget.
* Run: npx tsx scripts/preview-dashboard.ts [width] [--no-milestone] [--narrow] [--unhealthy]
*
* Renders the two-column layout with mock data so you can see
* exactly how it looks at any terminal width.
*
* Examples:
* npx tsx scripts/preview-dashboard.ts # default 120 cols, with milestone
* npx tsx scripts/preview-dashboard.ts 80 # narrow single-column
* npx tsx scripts/preview-dashboard.ts --no-milestone # compact no-milestone view
* npx tsx scripts/preview-dashboard.ts --unhealthy # yellow/red health states
* npx tsx scripts/preview-dashboard.ts --narrow # force 80 cols
*/
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import { makeUI, GLYPH, INDENT } from "../src/resources/extensions/shared/mod.js";
// ── Minimal ANSI color theme (no Theme class dependency) ────────────────
const COLORS: Record<string, string> = {
accent: "\x1b[36m", // cyan
dim: "\x1b[2m", // dim
text: "\x1b[37m", // white
success: "\x1b[32m", // green
error: "\x1b[31m", // red
warning: "\x1b[33m", // yellow
muted: "\x1b[90m", // gray
};
const RESET_FG = "\x1b[22m\x1b[39m";
const theme = {
fg(color: string, text: string): string {
const ansi = COLORS[color] ?? COLORS.text;
return `${ansi}${text}${RESET_FG}`;
},
bold(text: string): string {
return `\x1b[1m${text}\x1b[22m`;
},
};
// ── CLI args ────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const noMilestone = args.includes("--no-milestone");
const forceNarrow = args.includes("--narrow");
const unhealthy = args.includes("--unhealthy");
const modeArg = args.find(a => ["--small", "--min"].includes(a));
const widgetMode = modeArg === "--small" ? "small" : modeArg === "--min" ? "min" : "full";
const widthArg = args.find(a => /^\d+$/.test(a));
const width = forceNarrow ? 80 : (parseInt(widthArg ?? "", 10) || process.stdout.columns || 120);
// ── Mock data ───────────────────────────────────────────────────────────
const mockTasks = [
{ id: "T01", title: "Core type definitions & interfaces", done: true },
{ id: "T02", title: "Database schema migration", done: true },
{ id: "T03", title: "API route handlers", done: true },
{ id: "T04", title: "Authentication middleware", done: false },
{ id: "T05", title: "Unit & integration tests", done: false },
{ id: "T06", title: "Documentation updates", done: false },
];
const currentTaskId = "T04";
const milestoneTitle = "Core Patching Daemon";
const sliceId = "S04";
const sliceTitle = "CI gate";
const unitId = noMilestone ? "some-unit-id" : "M001-07dqzj/S04";
const verb = noMilestone ? "executing" : "completing";
const phaseLabel = noMilestone ? "EXECUTE" : "COMPLETE";
const modeTag = "AUTO";
const elapsed = "1h 23m";
const slicesDone = 3;
const slicesTotal = 6;
const taskNum = 4;
const taskTotal = 6;
const etaShort = "~47m left";
const pwd = noMilestone
? "my-project (main)"
: "worktrees/M001 (\u2387 M001-07dqzj)";
// Mock token/cost stats — simplified 3 items
const mockHitRate = 85;
const mockCost = "$18.67";
const mockCtxUsage = "35%/200k";
const modelDisplay = "anthropic/claude-opus-4-6";
// Mock last commit
const lastCommitTimeAgo = "3m";
const lastCommitMessage = "fix auth middleware";
// Health states
const healthStates = unhealthy
? [
{ icon: "!", color: "warning", summary: "Struggling — 2 consecutive error unit(s)" },
{ icon: "x", color: "error", summary: "Stuck — 4 consecutive error units" },
]
: [{ icon: "o", color: "success", summary: "Progressing well" }];
// ── Render helpers ──────────────────────────────────────────────────────
function rightAlign(left: string, right: string, w: number): string {
const leftVis = visibleWidth(left);
const rightVis = visibleWidth(right);
const gap = Math.max(1, w - leftVis - rightVis);
return truncateToWidth(left + " ".repeat(gap) + right, w);
}
function padToWidth(s: string, colWidth: number): string {
const vis = visibleWidth(s);
if (vis >= colWidth) return truncateToWidth(s, colWidth);
return s + " ".repeat(colWidth - vis);
}
// ── Render ──────────────────────────────────────────────────────────────
function render(w: number, healthState: { icon: string; color: string; summary: string }): string[] {
const ui = makeUI(theme as any, w);
const lines: string[] = [];
const pad = INDENT.base;
// Top bar
lines.push(...ui.bar());
// Header: GSD AUTO + health ... elapsed + ETA
const dot = theme.fg("accent", GLYPH.statusActive);
const healthIcon = healthState.color === "success" ? "o"
: healthState.color === "warning" ? "!"
: "x";
const healthStr = ` ${theme.fg(healthState.color, healthIcon)} ${theme.fg(healthState.color, healthState.summary)}`;
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}${healthStr}`;
const headerRight = `${theme.fg("dim", elapsed)} ${theme.fg("dim", "·")} ${theme.fg("dim", etaShort)}`;
lines.push(rightAlign(headerLeft, headerRight, w));
// ── min mode: header only ──────────────────────────────────────────
if (widgetMode === "min") {
lines.push(...ui.bar());
return lines;
}
// ── small mode: header + action + progress + compact stats ─────────
if (widgetMode === "small") {
lines.push("");
const target = noMilestone ? unitId : `${currentTaskId}: ${mockTasks.find(t => t.id === currentTaskId)!.title}`;
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
lines.push(rightAlign(actionLeft, theme.fg("dim", phaseLabel), w));
if (!noMilestone) {
const barWidth = Math.max(6, Math.min(18, Math.floor(w * 0.25)));
const pct = slicesDone / slicesTotal;
const filled = Math.round(pct * barWidth);
const bar = theme.fg("success", "━".repeat(filled))
+ theme.fg("dim", "─".repeat(barWidth - filled));
const meta = `${theme.fg("text", `${slicesDone}`)}${theme.fg("dim", `/${slicesTotal} slices`)}` +
`${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${taskTotal}`)}`;
lines.push(`${pad}${bar} ${meta}`);
}
const smallStats = [
theme.fg("warning", "$18.67"),
theme.fg("dim", "35.2%ctx"),
];
lines.push(rightAlign("", smallStats.join(theme.fg("dim", " ")), w));
lines.push(...ui.bar());
return lines;
}
// ── full mode ──────────────────────────────────────────────────────
lines.push("");
// Context section: milestone + slice + model
if (!noMilestone) {
const modelTag = theme.fg("muted", ` ${modelDisplay}`);
lines.push(truncateToWidth(`${pad}${theme.fg("dim", milestoneTitle)}${modelTag}`, w));
lines.push(truncateToWidth(
`${pad}${theme.fg("text", theme.bold(`${sliceId}: ${sliceTitle}`))}`,
w,
));
lines.push("");
}
// Action line
const target = noMilestone ? unitId : `${currentTaskId}: ${mockTasks.find(t => t.id === currentTaskId)!.title}`;
const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
const phaseBadge = theme.fg("dim", phaseLabel);
lines.push(rightAlign(actionLeft, phaseBadge, w));
lines.push("");
// Two-column body — pad left to fixed width, concatenate right
const minTwoColWidth = 76;
const hasTasks = !noMilestone;
const useTwoCol = w >= minTwoColWidth && hasTasks;
const leftColWidth = useTwoCol
? Math.floor(w * (w >= 100 ? 0.45 : 0.50))
: w;
// Left column
const leftLines: string[] = [];
if (!noMilestone) {
const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4)));
const pct = slicesDone / slicesTotal;
const filled = Math.round(pct * barWidth);
const bar = theme.fg("success", "━".repeat(filled))
+ theme.fg("dim", "─".repeat(barWidth - filled));
const meta = `${theme.fg("text", `${slicesDone}`)}${theme.fg("dim", `/${slicesTotal} slices`)}`
+ `${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${taskTotal}`)}`;
leftLines.push(`${pad}${bar} ${meta}`);
}
// Right column: task checklist — ASCII glyphs only (* > .)
const rightLines: string[] = [];
function fmtTask(t: typeof mockTasks[0]): string {
const isCurrent = t.id === currentTaskId;
const glyph = t.done
? theme.fg("success", "*")
: isCurrent
? theme.fg("accent", ">")
: theme.fg("dim", ".");
const id = isCurrent
? theme.fg("accent", t.id)
: t.done
? theme.fg("muted", t.id)
: theme.fg("dim", t.id);
const title = isCurrent
? theme.fg("text", t.title)
: t.done
? theme.fg("muted", t.title)
: theme.fg("text", t.title);
return `${glyph} ${id}: ${title}`;
}
if (useTwoCol) {
for (const t of mockTasks) rightLines.push(fmtTask(t));
} else if (hasTasks) {
for (const t of mockTasks) leftLines.push(`${pad}${fmtTask(t)}`);
}
// Compose columns — pad left to fixed width, concatenate right
if (useTwoCol) {
const maxRows = Math.max(leftLines.length, rightLines.length);
lines.push("");
for (let i = 0; i < maxRows; i++) {
const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth), leftColWidth);
const right = rightLines[i] ?? "";
lines.push(`${left}${right}`);
}
} else {
lines.push("");
for (const l of leftLines) lines.push(truncateToWidth(l, w));
}
// Footer: simplified stats + pwd + last commit + hints
lines.push("");
const hitColor = mockHitRate >= 70 ? "success" : mockHitRate >= 40 ? "warning" : "error";
const statsParts = [
theme.fg(hitColor, `${mockHitRate}%hit`),
theme.fg("warning", mockCost),
theme.fg("dim", mockCtxUsage),
];
const statsStr = statsParts.join(theme.fg("dim", " "));
lines.push(rightAlign("", statsStr, w));
// PWD + last commit
const pwdStr = theme.fg("dim", pwd);
const commitStr = theme.fg("dim", `${lastCommitTimeAgo} ago: ${lastCommitMessage}`);
lines.push(rightAlign(`${pad}${pwdStr}`, truncateToWidth(commitStr, Math.floor(w * 0.45)), w));
// Hints
const hintStr = theme.fg("dim", "esc pause | ⌃⌥G dashboard");
lines.push(rightAlign("", hintStr, w));
lines.push(...ui.bar());
return lines;
}
// ── Main ────────────────────────────────────────────────────────────────
for (const healthState of healthStates) {
const label = noMilestone ? "no-milestone" : `${width} cols`;
console.log(`\n Preview: ${label}, health=${healthState.color}\n`);
for (const line of render(width, healthState)) {
console.log(line);
}
}
console.log();