* feat(dashboard): two-column layout with task checklist Redesign the auto-mode progress widget to use the full terminal width with a two-column layout: Left column (~55%): task checklist with done/active/pending glyphs Right column (~45%): progress bar, ETA, next step, token stats, model Additional changes: - Merge project name, slice, and action into a single context line - Tighten spacing (single spaces, compact hint separator) - Collapse 5 blank separator lines down to 2 - Cache task details (id, title, done) in slice progress cache - Footer merges pwd and keybinding hints onto one line * refactor(dashboard): swap columns — stats left, tasks right Move progress/ETA/tokens/model to the left column (45%) and task checklist to the right column (55%) for better visual scanning. * feat(dashboard): fixed-width right column with narrow fallback Peg the task checklist to a fixed 44-char right column so it stays readable at any width. The left column (stats/progress) flexes to fill remaining space. Below 80 cols, falls back to single-column stacked layout. Also adds scripts/preview-dashboard.ts — a visual test harness that renders the widget with mock data at any terminal width: npx tsx scripts/preview-dashboard.ts [width] * refactor(dashboard): swap columns — tasks left, stats right Move task checklist back to the left column (fixed 44 chars) and progress/ETA/tokens/model to the right column (flexes to fill). Narrow fallback (<80 cols) stacks tasks then progress inline. * refactor(dashboard): stats left, tasks pegged right with growing gap Both columns are fixed width (44 chars each). The gap between them grows as the terminal widens, keeping the task checklist anchored to the right edge. At narrow widths (<80), falls back to single-column with stats then tasks stacked. * refactor(dashboard): move task column to middle, adjacent to stats Both columns are now fixed-width and adjacent (44 + 3 + 44 = 91 chars). Empty space flows to the right instead of between columns. The layout stays stable regardless of terminal width. * refactor(dashboard): flex left column, fixed right with gap Left column now flexes to fill available space — no more truncation on wide terminals. Right column (task checklist) stays fixed at 44 chars with a 5-char gap before the divider. Min width for two-column mode raised to 100.
208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
/**
|
|
* Visual preview of the auto-mode dashboard widget.
|
|
* Run: npx tsx scripts/preview-dashboard.ts [width]
|
|
*
|
|
* Renders the two-column layout with mock data so you can see
|
|
* exactly how it looks at any terminal width.
|
|
*/
|
|
|
|
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`;
|
|
},
|
|
};
|
|
|
|
// ── 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 = "M001-07dqzj/S04";
|
|
const verb = "completing";
|
|
const phaseLabel = "COMPLETE";
|
|
const modeTag = "AUTO";
|
|
const elapsed = "1h 23m";
|
|
const slicesDone = 3;
|
|
const slicesTotal = 6;
|
|
const taskNum = 4;
|
|
const taskTotal = 6;
|
|
const eta = "~1h 47m remaining";
|
|
const nextStep = "reassess roadmap";
|
|
const pwd = "~/Github/git-patcher/.gsd/worktrees/M001-07dqzj (milestone/M001-07dqzj)";
|
|
const tokenStats = "↑22 ↓11k R1.1M W38k ⚡85% $18.668";
|
|
const contextStats = "35.2%/200k";
|
|
const modelDisplay = "anthropic/claude-opus-4-6";
|
|
|
|
// ── Render helpers ──────────────────────────────────────────────────────
|
|
|
|
function rightAlign(left: string, right: string, width: number): string {
|
|
const leftVis = visibleWidth(left);
|
|
const rightVis = visibleWidth(right);
|
|
const gap = Math.max(1, width - leftVis - rightVis);
|
|
return truncateToWidth(left + " ".repeat(gap) + right, width);
|
|
}
|
|
|
|
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(width: number): string[] {
|
|
const ui = makeUI(theme as any, width);
|
|
const lines: string[] = [];
|
|
const pad = INDENT.base;
|
|
|
|
// Top bar
|
|
lines.push(...ui.bar());
|
|
|
|
// Header: GSD AUTO ... elapsed
|
|
const dot = theme.fg("accent", GLYPH.statusActive);
|
|
const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
|
|
const headerRight = theme.fg("dim", elapsed);
|
|
lines.push(rightAlign(headerLeft, headerRight, width));
|
|
|
|
// Context line: project · slice · action
|
|
const contextParts = [
|
|
theme.fg("dim", milestoneTitle),
|
|
theme.fg("text", theme.bold(`${sliceId}: ${sliceTitle}`)),
|
|
`${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", unitId)}`,
|
|
];
|
|
const phaseBadge = theme.fg("dim", phaseLabel);
|
|
const contextLine = contextParts.join(theme.fg("dim", " · "));
|
|
lines.push(rightAlign(`${pad}${contextLine}`, phaseBadge, width));
|
|
|
|
// Column sizing: left flexes, right fixed. Task list sits center-right.
|
|
const minTwoColWidth = 100;
|
|
const rightColFixed = 44;
|
|
const colGap = 5;
|
|
const useTwoCol = width >= minTwoColWidth;
|
|
const rightColWidth = useTwoCol ? rightColFixed : 0;
|
|
const leftColWidth = useTwoCol ? width - rightColWidth - colGap : width;
|
|
|
|
// Left column: progress, ETA, next, stats
|
|
const leftLines: string[] = [];
|
|
|
|
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("dim", `${slicesDone}/${slicesTotal} slices`)
|
|
+ theme.fg("dim", ` · task ${taskNum}/${taskTotal}`);
|
|
leftLines.push(truncateToWidth(`${pad}${bar} ${meta}`, leftColWidth));
|
|
leftLines.push(truncateToWidth(`${pad}${theme.fg("dim", eta)}`, leftColWidth));
|
|
leftLines.push(truncateToWidth(
|
|
`${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${nextStep}`)}`,
|
|
leftColWidth,
|
|
));
|
|
leftLines.push(truncateToWidth(
|
|
`${pad}${theme.fg("dim", tokenStats)} ${theme.fg("dim", contextStats)}`,
|
|
leftColWidth,
|
|
));
|
|
leftLines.push(truncateToWidth(`${pad}${theme.fg("dim", modelDisplay)}`, leftColWidth));
|
|
|
|
// Right column: task checklist (pegged to right edge)
|
|
const rightLines: string[] = [];
|
|
const rpad = " ";
|
|
|
|
if (useTwoCol) {
|
|
for (const t of mockTasks) {
|
|
const isCurrent = t.id === currentTaskId;
|
|
const glyph = t.done
|
|
? theme.fg("success", GLYPH.statusDone)
|
|
: isCurrent
|
|
? theme.fg("accent", "▸")
|
|
: theme.fg("dim", " ");
|
|
const label = isCurrent
|
|
? theme.fg("text", `${t.id}: ${t.title}`)
|
|
: t.done
|
|
? theme.fg("dim", `${t.id}: ${t.title}`)
|
|
: theme.fg("text", `${t.id}: ${t.title}`);
|
|
rightLines.push(truncateToWidth(`${rpad}${glyph} ${label}`, rightColWidth));
|
|
}
|
|
} else {
|
|
// Narrow: tasks + progress inline
|
|
for (const t of mockTasks) {
|
|
const isCurrent = t.id === currentTaskId;
|
|
const glyph = t.done
|
|
? theme.fg("success", GLYPH.statusDone)
|
|
: isCurrent
|
|
? theme.fg("accent", "▸")
|
|
: theme.fg("dim", " ");
|
|
const label = isCurrent
|
|
? theme.fg("text", `${t.id}: ${t.title}`)
|
|
: t.done
|
|
? theme.fg("dim", `${t.id}: ${t.title}`)
|
|
: theme.fg("text", `${t.id}: ${t.title}`);
|
|
leftLines.push(truncateToWidth(`${pad}${glyph} ${label}`, leftColWidth));
|
|
}
|
|
}
|
|
|
|
// Compose columns
|
|
if (useTwoCol) {
|
|
const divider = theme.fg("dim", "│");
|
|
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
lines.push("");
|
|
for (let i = 0; i < maxRows; i++) {
|
|
const left = padToWidth(leftLines[i] ?? "", leftColWidth);
|
|
const gap = " ".repeat(colGap - 2);
|
|
const right = rightLines[i] ?? "";
|
|
lines.push(truncateToWidth(`${left}${gap}${divider} ${right}`, width));
|
|
}
|
|
} else {
|
|
lines.push("");
|
|
for (const l of leftLines) lines.push(l);
|
|
}
|
|
|
|
// Footer
|
|
lines.push("");
|
|
const hintStr = theme.fg("dim", "esc pause | ⌃⌥G dashboard");
|
|
const pwdStr = theme.fg("dim", pwd);
|
|
lines.push(rightAlign(`${pad}${pwdStr}`, hintStr, width));
|
|
|
|
lines.push(...ui.bar());
|
|
|
|
return lines;
|
|
}
|
|
|
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
|
|
const width = parseInt(process.argv[2] ?? "", 10) || process.stdout.columns || 100;
|
|
console.log(`\nPreview at width=${width}:\n`);
|
|
for (const line of render(width)) {
|
|
console.log(line);
|
|
}
|
|
console.log();
|