diff --git a/scripts/preview-dashboard.ts b/scripts/preview-dashboard.ts new file mode 100644 index 000000000..5cf34428f --- /dev/null +++ b/scripts/preview-dashboard.ts @@ -0,0 +1,208 @@ +/** + * 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 = { + 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(); diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 675f6abd3..c4addc4cf 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -204,6 +204,13 @@ export function estimateTimeRemaining(): string | null { // ─── Slice Progress Cache ───────────────────────────────────────────────────── +/** Cached task detail for the widget task checklist */ +interface CachedTaskDetail { + id: string; + title: string; + done: boolean; +} + /** Cached slice progress for the widget — avoid async in render */ let cachedSliceProgress: { done: number; @@ -211,6 +218,8 @@ let cachedSliceProgress: { milestoneId: string; /** Real task progress for the active slice, if its plan file exists */ activeSliceTasks: { done: number; total: number } | null; + /** Full task list for the active slice checklist */ + taskDetails: CachedTaskDetail[] | null; } | null = null; export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void { @@ -221,6 +230,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?: const roadmap = parseRoadmap(content); let activeSliceTasks: { done: number; total: number } | null = null; + let taskDetails: CachedTaskDetail[] | null = null; if (activeSid) { try { const planFile = resolveSliceFile(base, mid, activeSid, "PLAN"); @@ -231,6 +241,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?: done: plan.tasks.filter(t => t.done).length, total: plan.tasks.length, }; + taskDetails = plan.tasks.map(t => ({ id: t.id, title: t.title, done: t.done })); } } catch { // Non-fatal — just omit task count @@ -242,13 +253,19 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?: total: roadmap.slices.length, milestoneId: mid, activeSliceTasks, + taskDetails, }; } catch { // Non-fatal — widget just won't show progress bar } } -export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null { +export function getRoadmapSlicesSync(): { + done: number; + total: number; + activeSliceTasks: { done: number; total: number } | null; + taskDetails: CachedTaskDetail[] | null; +} | null { return cachedSliceProgress; } @@ -349,87 +366,84 @@ export function updateProgressWidget( const lines: string[] = []; const pad = INDENT.base; - // ── Line 1: Top bar ─────────────────────────────────────────────── + // ── Top bar ───────────────────────────────────────────────────── lines.push(...ui.bar()); + // ── Header: GSD AUTO ... elapsed ──────────────────────────────── const dot = pulseBright ? theme.fg("accent", GLYPH.statusActive) : theme.fg("dim", GLYPH.statusPending); const elapsed = formatAutoElapsed(accessors.getAutoStartTime()); const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO"; - const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`; + const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`; const headerRight = elapsed ? theme.fg("dim", elapsed) : ""; lines.push(rightAlign(headerLeft, headerRight, width)); - lines.push(""); - - if (mid) { - lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width)); - } - + // ── Context: project · slice · action (merged into one line) ──── + const contextParts: string[] = []; + if (mid) contextParts.push(theme.fg("dim", mid.title)); if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") { - lines.push(truncateToWidth( - `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`, - width, - )); + contextParts.push(theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))); } - - lines.push(""); - const isHook = unitType.startsWith("hook/"); const target = isHook ? (unitId.split("/").pop() ?? unitId) : (task ? `${task.id}: ${task.title}` : unitId); - const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`; + contextParts.push(`${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`); + const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : ""; const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`; - lines.push(rightAlign(actionLeft, phaseBadge, width)); - lines.push(""); + const contextLine = contextParts.join(theme.fg("dim", " · ")); + lines.push(rightAlign(`${pad}${contextLine}`, phaseBadge, width)); - if (mid) { - const roadmapSlices = getRoadmapSlicesSync(); - if (roadmapSlices) { - const { done, total, activeSliceTasks } = roadmapSlices; - const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3))); - const pct = total > 0 ? done / total : 0; - const filled = Math.round(pct * barWidth); - const bar = theme.fg("success", "█".repeat(filled)) - + theme.fg("dim", "░".repeat(barWidth - filled)); + // ── Two-column body ───────────────────────────────────────────── + // Left: progress, ETA, next, stats (fixed) | Right: task checklist (fixed, adjacent) + // Both columns sit left-to-center; empty space is on the right. + const divider = theme.fg("dim", "│"); + const minTwoColWidth = 100; + const rightColFixed = 44; + const colGap = 5; // breathing room between columns + // Left column takes remaining space — no truncation on wide terminals + const useTwoCol = width >= minTwoColWidth; + const rightColWidth = useTwoCol ? rightColFixed : 0; + const leftColWidth = useTwoCol ? width - rightColWidth - colGap : width; - let meta = theme.fg("dim", `${done}/${total} slices`); + const roadmapSlices = mid ? getRoadmapSlicesSync() : null; - if (activeSliceTasks && activeSliceTasks.total > 0) { - // For hooks, show the trigger task number (done), not the next task (done + 1) - const taskNum = isHook - ? Math.max(activeSliceTasks.done, 1) - : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); - meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`); - } + // Build left column: progress bar, ETA, next step, token stats + const leftLines: string[] = []; - // ETA estimate - const eta = estimateTimeRemaining(); - if (eta) { - meta += theme.fg("dim", ` · ${eta}`); - } + if (roadmapSlices) { + const { done, total, activeSliceTasks } = roadmapSlices; + const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4))); + const pct = total > 0 ? done / total : 0; + const filled = Math.round(pct * barWidth); + const bar = theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); - lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width)); + let meta = theme.fg("dim", `${done}/${total} slices`); + if (activeSliceTasks && activeSliceTasks.total > 0) { + const taskNum = isHook + ? Math.max(activeSliceTasks.done, 1) + : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); + meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`); + } + leftLines.push(truncateToWidth(`${pad}${bar} ${meta}`, leftColWidth)); + + const eta = estimateTimeRemaining(); + if (eta) { + leftLines.push(truncateToWidth(`${pad}${theme.fg("dim", eta)}`, leftColWidth)); } } - lines.push(""); - if (next) { - lines.push(truncateToWidth( + leftLines.push(truncateToWidth( `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`, - width, + leftColWidth, )); } - // ── Footer info (pwd, tokens, cost, context, model) ────────────── - lines.push(""); - lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…"))); - - // Token stats from current unit session + cumulative cost from metrics + // Token stats { const cmdCtx = accessors.getCmdCtx(); let totalInput = 0, totalOutput = 0; @@ -464,7 +478,6 @@ export function updateProgressWidget( if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`); if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`); if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`); - // Cache hit rate for current unit if (totalCacheRead + totalInput > 0) { const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100); sp.push(`\u26A1${hitRate}%`); @@ -483,33 +496,134 @@ export function updateProgressWidget( sp.push(cxDisplay); } - const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p)) + const tokenLine = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p)) .join(theme.fg("dim", " ")); + leftLines.push(truncateToWidth(`${pad}${tokenLine}`, leftColWidth)); const modelId = cmdCtx?.model?.id ?? ""; const modelProvider = cmdCtx?.model?.provider ?? ""; - const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : ""; const modelDisplay = modelProvider && modelId ? `${modelProvider}/${modelId}` : modelId; - const sRight = modelDisplay - ? `${modelPhase}${theme.fg("dim", modelDisplay)}` - : ""; - lines.push(rightAlign(`${pad}${sLeft}`, sRight, width)); + if (modelDisplay) { + leftLines.push(truncateToWidth(`${pad}${theme.fg("dim", modelDisplay)}`, leftColWidth)); + } - // Dynamic routing savings summary + // Dynamic routing savings if (mLedger && mLedger.units.some(u => u.tier)) { const savings = formatTierSavings(mLedger.units); if (savings) { - lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width)); + leftLines.push(truncateToWidth(`${pad}${theme.fg("dim", savings)}`, leftColWidth)); } } } + // Build right column: task checklist (pegged to right edge) + const rightLines: string[] = []; + const taskDetails = roadmapSlices?.taskDetails ?? null; + const maxVisibleTasks = 8; + const rpad = " "; + + if (useTwoCol) { + if (taskDetails && taskDetails.length > 0) { + const visibleTasks = taskDetails.slice(0, maxVisibleTasks); + for (const t of visibleTasks) { + const isCurrent = task && t.id === task.id; + 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)); + } + if (taskDetails.length > maxVisibleTasks) { + rightLines.push(truncateToWidth( + `${rpad}${theme.fg("dim", ` …+${taskDetails.length - maxVisibleTasks} more`)}`, + rightColWidth, + )); + } + } else if (roadmapSlices?.activeSliceTasks) { + const { done: tDone, total: tTotal } = roadmapSlices.activeSliceTasks; + rightLines.push(`${rpad}${theme.fg("dim", `${tDone}/${tTotal} tasks`)}`); + } + } else { + // Narrow single-column: task list goes into left column + if (taskDetails && taskDetails.length > 0) { + for (const t of taskDetails.slice(0, maxVisibleTasks)) { + const isCurrent = task && t.id === task.id; + 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)); + } + } + // Add progress bar inline + if (roadmapSlices) { + const { done, total, activeSliceTasks } = roadmapSlices; + const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4))); + const pct = total > 0 ? done / total : 0; + const filled = Math.round(pct * barWidth); + const bar = theme.fg("success", "█".repeat(filled)) + + theme.fg("dim", "░".repeat(barWidth - filled)); + let meta = theme.fg("dim", `${done}/${total} slices`); + if (activeSliceTasks && activeSliceTasks.total > 0) { + const taskNum = isHook + ? Math.max(activeSliceTasks.done, 1) + : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total); + meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`); + } + const eta = estimateTimeRemaining(); + if (eta) meta += theme.fg("dim", ` · ${eta}`); + leftLines.push(truncateToWidth(`${pad}${bar} ${meta}`, leftColWidth)); + } + if (next) { + leftLines.push(truncateToWidth( + `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`, + leftColWidth, + )); + } + } + + // Compose columns + if (useTwoCol) { + const maxRows = Math.max(leftLines.length, rightLines.length); + if (maxRows > 0) { + lines.push(""); // spacer before columns + for (let i = 0; i < maxRows; i++) { + const left = padToWidth(leftLines[i] ?? "", leftColWidth); + const gap = " ".repeat(colGap - 2); // colGap minus divider and its trailing space + const right = rightLines[i] ?? ""; + lines.push(truncateToWidth(`${left}${gap}${divider} ${right}`, width)); + } + } + } else { + // Narrow single-column: just stack + if (leftLines.length > 0) { + lines.push(""); + for (const l of leftLines) lines.push(l); + } + } + + // ── Footer: pwd + hints ───────────────────────────────────────── + lines.push(""); const hintParts: string[] = []; hintParts.push("esc pause"); hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard"); - lines.push(...ui.hints(hintParts)); + const hintStr = theme.fg("dim", hintParts.join(" | ")); + const pwdStr = theme.fg("dim", widgetPwd); + lines.push(rightAlign(`${pad}${pwdStr}`, hintStr, width)); lines.push(...ui.bar()); @@ -597,3 +711,10 @@ function rightAlign(left: string, right: string, width: number): string { const gap = Math.max(1, width - leftVis - rightVis); return truncateToWidth(left + " ".repeat(gap) + right, width); } + +/** Pad a string with trailing spaces to fill exactly `colWidth` (ANSI-aware). */ +function padToWidth(s: string, colWidth: number): string { + const vis = visibleWidth(s); + if (vis >= colWidth) return truncateToWidth(s, colWidth); + return s + " ".repeat(colWidth - vis); +}