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:
Jeremy McSpadden 2026-03-18 13:26:02 -05:00 committed by GitHub
parent b0c3ab5781
commit d3780c9bdb
2 changed files with 390 additions and 61 deletions

View 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();

View file

@ -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);
}