fix: Two-column dashboard layout with task checklist (#1195)
* 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.
This commit is contained in:
parent
b0c3ab5781
commit
d3780c9bdb
2 changed files with 390 additions and 61 deletions
208
scripts/preview-dashboard.ts
Normal file
208
scripts/preview-dashboard.ts
Normal file
|
|
@ -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<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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue