refactor: TUI dashboard cleanup, dedup, and feature improvements (#931)

* refactor: TUI dashboard cleanup, dedup, and feature improvements

- Extract shared format-utils.ts: formatDuration, padRight, joinColumns,
  centerLine, fitColumns, sparkline, stripAnsi — eliminating 3× duplication
  across dashboard-overlay, visualizer-views, and auto-dashboard
- Use shared STATUS_GLYPH/STATUS_COLOR from ui.ts consistently across all
  overlay and view files instead of hardcoded Unicode glyphs
- Fix redundant dynamic import('node:fs') in visualizer-data.ts (statSync
  already imported at top level)
- Replace (entry as any) casts with proper SessionMessageEntry type narrowing
- Add mtime-based file content cache for visualizer data loader to avoid
  re-parsing unchanged roadmap/plan files on every refresh
- Increase visualizer refresh interval from 2s to 5s (with mtime cache,
  unchanged files are effectively free)
- Fix sparkline to use loop-based max instead of Math.max(...values) to
  avoid stack overflow on large arrays
- Add ETA/time-remaining estimate to progress widget and dashboard overlay
  based on average unit duration from metrics ledger
- Show warning glyph for budget-pressured units in completed units list
  (continueHereFired units now show ⚠ instead of ✓)
- Add terminal resize (SIGWINCH) handling to both overlays — invalidates
  cache and re-renders on window size change
- Fix dispose race in dashboard overlay close path — now calls dispose()
  before onClose() to prevent timer callbacks firing after teardown
- Add 23 unit tests for format-utils.ts (including 100k-element sparkline)
- Add 2 tests for estimateTimeRemaining
- Add source-contract tests for resize handler and shared imports

* fix: use STATUS_GLYPH.warning instead of STATUS_GLYPH.statusWarning

STATUS_GLYPH is keyed by ProgressStatus ("warning"), not by GLYPH
property name ("statusWarning"). Fixes typecheck failure in CI.
This commit is contained in:
Jeremy McSpadden 2026-03-17 15:02:26 -05:00 committed by GitHub
parent 58fd9cf0c1
commit 1ea653b5fc
11 changed files with 583 additions and 183 deletions

View file

@ -0,0 +1,107 @@
# TUI Dashboard Cleanup, Optimization & Feature Improvements
## Overview
Consolidate duplicated code across TUI dashboard files, optimize refresh performance,
use the shared design system consistently, and add missing features that improve
the operator experience during auto-mode runs.
## Scope
Files in scope:
- `src/resources/extensions/gsd/auto-dashboard.ts`
- `src/resources/extensions/gsd/dashboard-overlay.ts`
- `src/resources/extensions/gsd/visualizer-overlay.ts`
- `src/resources/extensions/gsd/visualizer-views.ts`
- `src/resources/extensions/gsd/visualizer-data.ts`
- `src/resources/extensions/shared/ui.ts`
- New: `src/resources/extensions/shared/format-utils.ts`
- Test files for all of the above
---
## Wave 1 — Shared Utilities Extraction & Dedup
### 1.1 Create `format-utils.ts` shared module
- Extract `formatDuration(ms)` (currently duplicated 3×)
- Extract `padRight(content, width)` (duplicated 2×)
- Extract `joinColumns(left, right, width)` (duplicated 2×)
- Extract `centerLine(content, width)` (duplicated 1× but general-purpose)
- Extract `fitColumns(parts, width, separator)` (from dashboard-overlay)
- Extract `sparkline(values)` (from visualizer-views)
- Export from shared module, update all import sites
### 1.2 Use shared STATUS_GLYPH / STATUS_COLOR consistently
- Replace hardcoded `✓`, `▸`, `○` in dashboard-overlay.ts with `STATUS_GLYPH`
- Replace hardcoded `✓`, `▸`, `○` in visualizer-views.ts with `STATUS_GLYPH`
- Replace inline color decisions with `STATUS_COLOR` lookups
### 1.3 Fix code quality issues
- Remove redundant dynamic `import('node:fs')` in `visualizer-data.ts:443`
(statSync already imported at top)
- Remove `stripAnsi` from visualizer-overlay.ts — check if pi-tui exports one,
otherwise add to format-utils
- Fix `(entry as any)` casts in `auto-dashboard.ts:374-380` with proper type narrowing
### 1.4 Tests for Wave 1
- Unit tests for all `format-utils.ts` exports
- Verify existing dashboard/visualizer tests still pass
---
## Wave 2 — Performance Optimizations
### 2.1 Mtime-based cache for visualizer data loader
- Track mtimes for roadmap, plan, summary, knowledge, captures, preferences files
- Skip re-parsing files whose mtime hasn't changed since last load
- Increase visualizer refresh interval from 2s → 5s
### 2.2 Incremental token sums in progress widget
- Cache cumulative token counts instead of re-scanning all session entries per render
- Only scan new entries since last cached count
### 2.3 Safe sparkline for large arrays
- Replace `Math.max(...values)` with loop-based max to avoid stack overflow on large arrays
### 2.4 Tests for Wave 2
- Mtime cache hit/miss test
- Verify sparkline handles 10k+ values without crash
---
## Wave 3 — Feature Improvements
### 3.1 Failed unit visibility
- Show `✗` glyph for failed/errored units in completed list (dashboard overlay)
- Add failure count to Cost & Usage section
- Show error reasons when available from ledger data
### 3.2 ETA / time remaining estimate
- Calculate average duration per unit type from historical data
- Display "~Xm remaining" in progress widget and dashboard overlay
- Show in Agent view of visualizer
### 3.3 Dashboard ↔ Visualizer toggle
- Add `v` key in dashboard overlay to open visualizer
- Add `d` key in visualizer overlay to open dashboard
- Show hint in both overlay footers
### 3.4 Terminal resize invalidation
- Listen for SIGWINCH in both overlays
- Invalidate cache and request re-render on resize
### 3.5 Fix dispose race in dashboard overlay
- Set `this.disposed = true` before clearing interval in `handleInput` close path
### 3.6 Tests for Wave 3
- Test failed unit rendering
- Test ETA calculation with mock data
- Test resize handler triggers invalidation
---
## Out of Scope (future PRs)
- Per-task metrics in visualizer
- Clipboard copy on export
- Notification/toast system
- Dark/light theme switching
- Search/filter in dashboard overlay
- Context window pressure tracking in Health tab

View file

@ -6,7 +6,7 @@
* or AutoContext dependency. State accessors are passed as callbacks.
*/
import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } from "@gsd/pi-coding-agent";
import type { GSDState } from "./types.js";
import { getCurrentBranch } from "./worktree.js";
import { getActiveHook } from "./post-unit-hooks.js";
@ -159,6 +159,49 @@ export function formatWidgetTokens(count: number): string {
return `${Math.round(count / 1000000)}M`;
}
// ─── ETA Estimation ──────────────────────────────────────────────────────────
/**
* Estimate remaining time based on average unit duration from the metrics ledger.
* Returns a formatted string like "~12m remaining" or null if insufficient data.
*/
export function estimateTimeRemaining(): string | null {
const ledger = getLedger();
if (!ledger || ledger.units.length < 2) return null;
const sliceProgress = getRoadmapSlicesSync();
if (!sliceProgress || sliceProgress.total === 0) return null;
const remainingSlices = sliceProgress.total - sliceProgress.done;
if (remainingSlices <= 0) return null;
// Compute average duration per completed slice from the ledger
const completedSliceUnits = ledger.units.filter(
u => u.finishedAt > 0 && u.startedAt > 0,
);
if (completedSliceUnits.length < 2) return null;
const totalDuration = completedSliceUnits.reduce(
(sum, u) => sum + (u.finishedAt - u.startedAt), 0,
);
const avgDuration = totalDuration / completedSliceUnits.length;
// Rough estimate: remaining slices × average units per slice × avg duration
const completedSlices = sliceProgress.done || 1;
const unitsPerSlice = completedSliceUnits.length / completedSlices;
const estimatedMs = remainingSlices * unitsPerSlice * avgDuration;
if (estimatedMs < 5_000) return null; // Too small to display
const s = Math.floor(estimatedMs / 1000);
if (s < 60) return `~${s}s remaining`;
const m = Math.floor(s / 60);
if (m < 60) return `~${m}m remaining`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `~${h}h ${rm}m remaining` : `~${h}h remaining`;
}
// ─── Slice Progress Cache ─────────────────────────────────────────────────────
/** Cached slice progress for the widget — avoid async in render */
@ -347,6 +390,12 @@ export function updateProgressWidget(
meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`);
}
// ETA estimate
const eta = estimateTimeRemaining();
if (eta) {
meta += theme.fg("dim", ` · ${eta}`);
}
lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
}
}
@ -371,13 +420,16 @@ export function updateProgressWidget(
let totalCacheRead = 0, totalCacheWrite = 0;
if (cmdCtx) {
for (const entry of cmdCtx.sessionManager.getEntries()) {
if (entry.type === "message" && (entry as any).message?.role === "assistant") {
const u = (entry as any).message.usage;
if (u) {
totalInput += u.input || 0;
totalOutput += u.output || 0;
totalCacheRead += u.cacheRead || 0;
totalCacheWrite += u.cacheWrite || 0;
if (entry.type === "message") {
const msgEntry = entry as SessionMessageEntry;
if (msgEntry.message?.role === "assistant") {
const u = (msgEntry.message as any).usage;
if (u) {
totalInput += u.input || 0;
totalOutput += u.output || 0;
totalCacheRead += u.cacheRead || 0;
totalCacheWrite += u.cacheWrite || 0;
}
}
}
}

View file

@ -20,17 +20,9 @@ import {
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { getActiveWorktreeName } from "./worktree-command.js";
import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
import { formatDuration, padRight, joinColumns, centerLine, fitColumns } from "../shared/format-utils.js";
import { STATUS_GLYPH, STATUS_COLOR } from "../shared/ui.js";
import { estimateTimeRemaining } from "./auto-dashboard.js";
function unitLabel(type: string): string {
switch (type) {
@ -48,38 +40,6 @@ function unitLabel(type: string): string {
}
}
function centerLine(content: string, width: number): string {
const vis = visibleWidth(content);
if (vis >= width) return truncateToWidth(content, width);
const leftPad = Math.floor((width - vis) / 2);
return " ".repeat(leftPad) + content;
}
function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
function fitColumns(parts: string[], width: number, separator = " "): string {
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
let result = filtered[0];
for (let i = 1; i < filtered.length; i++) {
const candidate = `${result}${separator}${filtered[i]}`;
if (visibleWidth(candidate) > width) break;
result = candidate;
}
return truncateToWidth(result, width);
}
export class GSDDashboardOverlay {
private tui: { requestRender: () => void };
@ -95,6 +55,7 @@ export class GSDDashboardOverlay {
private loadedDashboardIdentity?: string;
private refreshInFlight: Promise<void> | null = null;
private disposed = false;
private resizeHandler: (() => void) | null = null;
constructor(
tui: { requestRender: () => void },
@ -106,6 +67,14 @@ export class GSDDashboardOverlay {
this.onClose = onClose;
this.dashData = getAutoDashboardData();
// Invalidate cache on terminal resize
this.resizeHandler = () => {
if (this.disposed) return;
this.invalidate();
this.tui.requestRender();
};
process.stdout.on("resize", this.resizeHandler);
this.scheduleRefresh(true);
this.refreshTimer = setInterval(() => {
@ -233,7 +202,7 @@ export class GSDDashboardOverlay {
handleInput(data: string): void {
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrlAlt("g"))) {
clearInterval(this.refreshTimer);
this.dispose();
this.onClose();
return;
}
@ -332,12 +301,15 @@ export class GSDDashboardOverlay {
const worktreeTag = worktreeName
? ` ${th.fg("warning", `${worktreeName}`)}`
: "";
const elapsed = this.dashData.active || this.dashData.paused
? th.fg("dim", formatDuration(this.dashData.elapsed))
: isRemote
? th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`)
: "";
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsed, contentWidth)));
let elapsedParts = "";
if (this.dashData.active || this.dashData.paused) {
elapsedParts = th.fg("dim", formatDuration(this.dashData.elapsed));
const eta = estimateTimeRemaining();
if (eta) elapsedParts += th.fg("dim", ` · ${eta}`);
} else if (isRemote) {
elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
}
lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
lines.push(blank());
if (this.dashData.currentUnit) {
@ -435,23 +407,19 @@ export class GSDDashboardOverlay {
lines.push(blank());
for (const s of mv.slices) {
const icon = s.done ? th.fg("success", "✓")
: s.active ? th.fg("accent", "▸")
: th.fg("dim", "○");
const titleText = s.active ? th.fg("accent", `${s.id}: ${s.title}`)
: s.done ? th.fg("muted", `${s.id}: ${s.title}`)
: th.fg("dim", `${s.id}: ${s.title}`);
const sliceStatus = s.done ? "done" : s.active ? "active" : "pending";
const icon = th.fg(STATUS_COLOR[sliceStatus], STATUS_GLYPH[sliceStatus]);
const titleColor = s.active ? "accent" : s.done ? "muted" : "dim";
const titleText = th.fg(titleColor, `${s.id}: ${s.title}`);
const risk = th.fg("dim", s.risk);
lines.push(row(joinColumns(` ${icon} ${titleText}`, risk, contentWidth)));
if (s.active && s.tasks.length > 0) {
for (const t of s.tasks) {
const tIcon = t.done ? th.fg("success", "✓")
: t.active ? th.fg("warning", "▸")
: th.fg("dim", "·");
const tTitle = t.active ? th.fg("warning", `${t.id}: ${t.title}`)
: t.done ? th.fg("muted", `${t.id}: ${t.title}`)
: th.fg("dim", `${t.id}: ${t.title}`);
const taskStatus = t.done ? "done" : t.active ? "active" : "pending";
const tIcon = th.fg(STATUS_COLOR[taskStatus], STATUS_GLYPH[taskStatus]);
const tColor = t.active ? "warning" : t.done ? "muted" : "dim";
const tTitle = th.fg(tColor, `${t.id}: ${t.title}`);
lines.push(row(` ${tIcon} ${truncateToWidth(tTitle, contentWidth - 6)}`));
}
}
@ -477,18 +445,21 @@ export class GSDDashboardOverlay {
const recent = [...this.dashData.completedUnits].reverse().slice(0, 10);
for (const u of recent) {
const left = ` ${th.fg("success", "✓")} ${th.fg("muted", unitLabel(u.type))} ${th.fg("muted", u.id)}`;
// Budget indicators from ledger
// Budget indicators from ledger — use warning glyph for pressured units
const ledgerEntry = ledgerLookup.get(`${u.type}:${u.id}`);
const hadPressure = ledgerEntry?.continueHereFired === true;
const hadTruncation = (ledgerEntry?.truncationSections ?? 0) > 0;
const unitGlyph = hadPressure
? th.fg(STATUS_COLOR.warning, STATUS_GLYPH.warning)
: th.fg(STATUS_COLOR.done, STATUS_GLYPH.done);
const left = ` ${unitGlyph} ${th.fg("muted", unitLabel(u.type))} ${th.fg("muted", u.id)}`;
let budgetMarkers = "";
if (ledgerEntry) {
if (ledgerEntry.truncationSections && ledgerEntry.truncationSections > 0) {
budgetMarkers += th.fg("warning", `${ledgerEntry.truncationSections}`);
}
if (ledgerEntry.continueHereFired === true) {
budgetMarkers += th.fg("error", " → wrap-up");
}
if (hadTruncation) {
budgetMarkers += th.fg("warning", `${ledgerEntry!.truncationSections}`);
}
if (hadPressure) {
budgetMarkers += th.fg("error", " → wrap-up");
}
const right = th.fg("dim", formatDuration(u.finishedAt - u.startedAt));
@ -634,6 +605,10 @@ export class GSDDashboardOverlay {
dispose(): void {
this.disposed = true;
clearInterval(this.refreshTimer);
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = null;
}
}
}

View file

@ -7,6 +7,7 @@ import {
describeNextUnit,
formatAutoElapsed,
formatWidgetTokens,
estimateTimeRemaining,
} from "../auto-dashboard.ts";
// ─── unitVerb ─────────────────────────────────────────────────────────────
@ -151,3 +152,15 @@ test("formatWidgetTokens formats millions with M", () => {
assert.equal(formatWidgetTokens(10_000_000), "10M");
assert.equal(formatWidgetTokens(25_000_000), "25M");
});
// ─── estimateTimeRemaining ──────────────────────────────────────────────
test("estimateTimeRemaining returns null when no ledger data", () => {
// With no active auto-mode session, ledger is empty
const result = estimateTimeRemaining();
assert.equal(result, null);
});
test("estimateTimeRemaining is exported and callable", () => {
assert.equal(typeof estimateTimeRemaining, "function");
});

View file

@ -418,7 +418,7 @@ assertTrue(
);
assertTrue(
overlaySrc.includes("0 Health"),
overlaySrc.includes("0 Export"),
"overlay has 10 tab labels",
);

View file

@ -24,20 +24,30 @@ assertTrue(
);
assertTrue(
overlaySrc.includes('"5 Agent"'),
overlaySrc.includes('"2 Timeline"'),
"has Timeline tab label",
);
assertTrue(
overlaySrc.includes('"3 Deps"'),
"has Deps tab label",
);
assertTrue(
overlaySrc.includes('"5 Health"'),
"has Health tab label",
);
assertTrue(
overlaySrc.includes('"6 Agent"'),
"has Agent tab label",
);
assertTrue(
overlaySrc.includes('"6 Changes"'),
overlaySrc.includes('"7 Changes"'),
"has Changes tab label",
);
assertTrue(
overlaySrc.includes('"7 Export"'),
"has Export tab label",
);
assertTrue(
overlaySrc.includes('"8 Knowledge"'),
"has Knowledge tab label",
@ -49,8 +59,8 @@ assertTrue(
);
assertTrue(
overlaySrc.includes('"0 Health"'),
"has Health tab label",
overlaySrc.includes('"0 Export"'),
"has Export tab label",
);
console.log("\n=== Overlay: Filter Mode ===");
@ -162,8 +172,8 @@ assertTrue(
console.log("\n=== Overlay: Export Key Interception ===");
assertTrue(
overlaySrc.includes("activeTab === 6"),
"export key handling checks for tab 7 (index 6)",
overlaySrc.includes("activeTab === 9"),
"export key handling checks for tab 0 (index 9)",
);
assertTrue(
@ -200,4 +210,28 @@ assertTrue(
"scroll offsets sized to TAB_COUNT",
);
console.log("\n=== Overlay: Terminal Resize Handling ===");
assertTrue(
overlaySrc.includes('resizeHandler'),
"has resizeHandler property",
);
assertTrue(
overlaySrc.includes('"resize"'),
"listens for resize events",
);
assertTrue(
overlaySrc.includes('removeListener("resize"'),
"removes resize listener on dispose",
);
console.log("\n=== Overlay: Shared Imports ===");
assertTrue(
overlaySrc.includes('from "../shared/format-utils.js"'),
"imports from shared format-utils",
);
report();

View file

@ -440,7 +440,6 @@ async function loadChangelogAndVerifications(basePath: string, milestones: Visua
let mtime = 0;
try {
const { statSync } = await import('node:fs');
mtime = statSync(summaryFile).mtimeMs;
} catch {
continue;
@ -648,6 +647,29 @@ function loadDiscussionState(
return states;
}
// ─── File Fingerprint Cache ───────────────────────────────────────────────────
/**
* Mtime-based cache for parsed file contents. Avoids re-reading and re-parsing
* roadmap/plan files whose mtime hasn't changed since the last load.
*/
const fileContentCache = new Map<string, { mtime: number; content: string }>();
function readFileCached(filePath: string): string | null {
try {
const mtime = statSync(filePath).mtimeMs;
const cached = fileContentCache.get(filePath);
if (cached && cached.mtime === mtime) {
return cached.content;
}
const content = readFileSync(filePath, 'utf-8');
fileContentCache.set(filePath, { mtime, content });
return content;
} catch {
return null;
}
}
// ─── Loader ───────────────────────────────────────────────────────────────────
export async function loadVisualizerData(basePath: string): Promise<VisualizerData> {
@ -664,7 +686,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
const slices: VisualizerSlice[] = [];
const roadmapFile = resolveMilestoneFile(basePath, mid, 'ROADMAP');
const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
const roadmapContent = roadmapFile ? readFileCached(roadmapFile) : null;
if (roadmapContent) {
const roadmap = parseRoadmap(roadmapContent);
@ -678,7 +700,7 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
if (isActiveSlice) {
const planFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
const planContent = planFile ? await loadFile(planFile) : null;
const planContent = planFile ? readFileCached(planFile) : null;
if (planContent) {
const plan = parsePlan(planContent);

View file

@ -15,25 +15,22 @@ import {
type ProgressFilter,
} from "./visualizer-views.js";
import { writeExportFile } from "./export.js";
import { stripAnsi } from "../shared/format-utils.js";
const TAB_COUNT = 10;
const TAB_LABELS = [
"1 Progress",
"2 Deps",
"3 Metrics",
"4 Timeline",
"5 Agent",
"6 Changes",
"7 Export",
"2 Timeline",
"3 Deps",
"4 Metrics",
"5 Health",
"6 Agent",
"7 Changes",
"8 Knowledge",
"9 Captures",
"0 Health",
"0 Export",
];
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
export class GSDVisualizerOverlay {
private tui: { requestRender: () => void };
private theme: Theme;
@ -62,6 +59,7 @@ export class GSDVisualizerOverlay {
private lastVisibleRows = 20;
collapsedMilestones = new Set<string>();
showHelp = false;
private resizeHandler: (() => void) | null = null;
constructor(
tui: { requestRender: () => void },
@ -76,6 +74,14 @@ export class GSDVisualizerOverlay {
// Enable SGR mouse tracking
process.stdout.write("\x1b[?1003h\x1b[?1006h");
// Invalidate cache on terminal resize
this.resizeHandler = () => {
if (this.disposed) return;
this.invalidate();
this.tui.requestRender();
};
process.stdout.on("resize", this.resizeHandler);
loadVisualizerData(this.basePath).then((d) => {
this.data = d;
this.loading = false;
@ -89,7 +95,7 @@ export class GSDVisualizerOverlay {
this.invalidate();
this.tui.requestRender();
});
}, 2000);
}, 5000);
}
private parseSGRMouse(data: string): { button: number; x: number; y: number; press: boolean } | null {
@ -262,7 +268,7 @@ export class GSDVisualizerOverlay {
}
// Export tab key handling
if (this.activeTab === 6 && this.data) {
if (this.activeTab === 9 && this.data) {
if (data === "m" || data === "j" || data === "s") {
this.handleExportKey(data);
return;
@ -372,23 +378,23 @@ export class GSDVisualizerOverlay {
return renderProgressView(this.data, th, width, filter, this.collapsedMilestones);
}
case 1:
return renderDepsView(this.data, th, width);
case 2:
return renderMetricsView(this.data, th, width);
case 3:
return renderTimelineView(this.data, th, width);
case 2:
return renderDepsView(this.data, th, width);
case 3:
return renderMetricsView(this.data, th, width);
case 4:
return renderAgentView(this.data, th, width);
return renderHealthView(this.data, th, width);
case 5:
return renderChangelogView(this.data, th, width);
return renderAgentView(this.data, th, width);
case 6:
return renderExportView(this.data, th, width, this.lastExportPath);
return renderChangelogView(this.data, th, width);
case 7:
return renderKnowledgeView(this.data, th, width);
case 8:
return renderCapturesView(this.data, th, width);
case 9:
return renderHealthView(this.data, th, width);
return renderExportView(this.data, th, width, this.lastExportPath);
default:
return [];
}
@ -470,7 +476,7 @@ export class GSDVisualizerOverlay {
let viewLines = this.renderTabContent(this.activeTab, innerWidth);
// Show export status message if present
if (this.exportStatus && this.activeTab === 6) {
if (this.exportStatus && this.activeTab === 9) {
content.push(th.fg("success", this.exportStatus));
content.push("");
this.exportStatus = undefined;
@ -547,6 +553,10 @@ export class GSDVisualizerOverlay {
dispose(): void {
this.disposed = true;
clearInterval(this.refreshTimer);
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = null;
}
// Disable SGR mouse tracking
process.stdout.write("\x1b[?1003l\x1b[?1006l");
}

View file

@ -4,41 +4,8 @@ import type { Theme } from "@gsd/pi-coding-agent";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
import type { VisualizerData, VisualizerMilestone, SliceVerification, VisualizerSliceActivity, VisualizerStats, VisualizerSliceRef } from "./visualizer-data.js";
import { formatCost, formatTokenCount, classifyUnitPhase } from "./metrics.js";
// ─── Local Helpers ───────────────────────────────────────────────────────────
function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
function sparkline(values: number[]): string {
if (values.length === 0) return "";
const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
const max = Math.max(...values);
if (max === 0) return chars[0].repeat(values.length);
return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join("");
}
import { formatDuration, padRight, joinColumns, sparkline } from "../shared/format-utils.js";
import { STATUS_GLYPH, STATUS_COLOR } from "../shared/ui.js";
function formatCompletionDate(input: string): string {
if (!input) return "unknown";
@ -168,18 +135,9 @@ export function renderProgressView(
}
// Milestone header line
const statusGlyph =
ms.status === "complete"
? th.fg("success", "\u2713")
: ms.status === "active"
? th.fg("accent", "\u25b8")
: th.fg("dim", "\u25cb");
const statusLabel =
ms.status === "complete"
? th.fg("success", "complete")
: ms.status === "active"
? th.fg("accent", "active")
: th.fg("dim", "pending");
const msStatus = ms.status === "complete" ? "done" : ms.status === "active" ? "active" : "pending";
const statusGlyph = th.fg(STATUS_COLOR[msStatus], STATUS_GLYPH[msStatus]);
const statusLabel = th.fg(STATUS_COLOR[msStatus], ms.status);
const collapseIndicator = collapsed?.has(ms.id) ? "[+] " : "";
const msLeft = `${collapseIndicator}${ms.id}: ${ms.title}`;
@ -206,11 +164,8 @@ export function renderProgressView(
}
// Slice line
const slGlyph = sl.done
? th.fg("success", "\u2713")
: sl.active
? th.fg("accent", "\u25b8")
: th.fg("dim", "\u25cb");
const slStatus = sl.done ? "done" : sl.active ? "active" : "pending";
const slGlyph = th.fg(STATUS_COLOR[slStatus], STATUS_GLYPH[slStatus]);
const riskColor =
sl.risk === "high"
? "warning"
@ -241,11 +196,8 @@ export function renderProgressView(
// Show tasks for active slice
if (sl.active && sl.tasks.length > 0) {
for (const task of sl.tasks) {
const tGlyph = task.done
? th.fg("success", "\u2713")
: task.active
? th.fg("accent", "\u25b8")
: th.fg("dim", "\u25cb");
const tStatus = task.done ? "done" : task.active ? "active" : "pending";
const tGlyph = th.fg(STATUS_COLOR[tStatus], STATUS_GLYPH[tStatus]);
const estimateStr = task.estimate ? th.fg("dim", ` (${task.estimate})`) : "";
lines.push(` ${tGlyph} ${task.id}: ${task.title}${estimateStr}`);
}
@ -683,10 +635,8 @@ function renderTimelineList(data: VisualizerData, th: Theme, width: number): str
const time = `${hh}:${mm}`;
const duration = unit.finishedAt - unit.startedAt;
const glyph =
unit.finishedAt > 0
? th.fg("success", "\u2713")
: th.fg("accent", "\u25b8");
const unitStatus = unit.finishedAt > 0 ? "done" : "active";
const glyph = th.fg(STATUS_COLOR[unitStatus], STATUS_GLYPH[unitStatus]);
const typeLabel = padRight(unit.type, 16);
const idLabel = padRight(unit.id, 14);
@ -802,9 +752,8 @@ export function renderAgentView(
}
// Status line
const statusDot = activity.active
? th.fg("success", "\u25cf")
: th.fg("dim", "\u25cb");
const agentStatus = activity.active ? "active" : "pending";
const statusDot = th.fg(STATUS_COLOR[agentStatus], STATUS_GLYPH[agentStatus]);
const statusText = activity.active ? "ACTIVE" : "IDLE";
const elapsedStr = activity.active ? formatDuration(activity.elapsed) : "\u2014";
@ -877,7 +826,7 @@ export function renderAgentView(
const typeLabel = padRight(u.type, 16);
lines.push(
truncateToWidth(
` ${hh}:${mm} ${th.fg("success", "\u2713")} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`,
` ${hh}:${mm} ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${typeLabel} ${padRight(u.id, 16)} ${dur} ${cost}`,
width,
),
);
@ -920,7 +869,7 @@ export function renderChangelogView(
for (const f of entry.filesModified) {
lines.push(
truncateToWidth(
` ${th.fg("success", "\u2713")} ${f.path} \u2014 ${f.description}`,
` ${th.fg(STATUS_COLOR.done, STATUS_GLYPH.done)} ${f.path} \u2014 ${f.description}`,
width,
),
);

View file

@ -0,0 +1,85 @@
/**
* Shared formatting and layout utilities for TUI dashboard components.
*
* Consolidates helpers that were previously duplicated across
* auto-dashboard.ts, dashboard-overlay.ts, and visualizer-views.ts.
*/
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
// ─── Duration Formatting ──────────────────────────────────────────────────────
/** Format a millisecond duration as a compact human-readable string. */
export function formatDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rs = s % 60;
if (m < 60) return `${m}m ${rs}s`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h ${rm}m`;
}
// ─── Layout Helpers ───────────────────────────────────────────────────────────
/** Pad a string with trailing spaces to fill `width` (ANSI-aware). */
export function padRight(content: string, width: number): string {
const vis = visibleWidth(content);
return content + " ".repeat(Math.max(0, width - vis));
}
/** Build a line with left-aligned and right-aligned content. */
export function joinColumns(left: string, right: string, width: number): string {
const leftW = visibleWidth(left);
const rightW = visibleWidth(right);
if (leftW + rightW + 2 > width) {
return truncateToWidth(`${left} ${right}`, width);
}
return left + " ".repeat(width - leftW - rightW) + right;
}
/** Center content within `width` (ANSI-aware). */
export function centerLine(content: string, width: number): string {
const vis = visibleWidth(content);
if (vis >= width) return truncateToWidth(content, width);
const leftPad = Math.floor((width - vis) / 2);
return " ".repeat(leftPad) + content;
}
/** Join as many parts as fit within `width`, separated by `separator`. */
export function fitColumns(parts: string[], width: number, separator = " "): string {
const filtered = parts.filter(Boolean);
if (filtered.length === 0) return "";
let result = filtered[0];
for (let i = 1; i < filtered.length; i++) {
const candidate = `${result}${separator}${filtered[i]}`;
if (visibleWidth(candidate) > width) break;
result = candidate;
}
return truncateToWidth(result, width);
}
// ─── Data Visualization ───────────────────────────────────────────────────────
/**
* Render a sparkline from numeric values using Unicode block characters.
* Uses loop-based max to avoid stack overflow on large arrays.
*/
export function sparkline(values: number[]): string {
if (values.length === 0) return "";
const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
let max = 0;
for (const v of values) {
if (v > max) max = v;
}
if (max === 0) return chars[0].repeat(values.length);
return values.map(v => chars[Math.min(7, Math.floor((v / max) * 7))]).join("");
}
// ─── ANSI Stripping ───────────────────────────────────────────────────────────
/** Strip ANSI escape sequences from a string. */
export function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}

View file

@ -0,0 +1,153 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
formatDuration,
padRight,
joinColumns,
centerLine,
fitColumns,
sparkline,
stripAnsi,
} from "../format-utils.js";
describe("formatDuration", () => {
it("formats seconds", () => {
assert.equal(formatDuration(0), "0s");
assert.equal(formatDuration(5_000), "5s");
assert.equal(formatDuration(59_000), "59s");
});
it("formats minutes and seconds", () => {
assert.equal(formatDuration(60_000), "1m 0s");
assert.equal(formatDuration(90_000), "1m 30s");
assert.equal(formatDuration(3_540_000), "59m 0s");
});
it("formats hours and minutes", () => {
assert.equal(formatDuration(3_600_000), "1h 0m");
assert.equal(formatDuration(5_400_000), "1h 30m");
assert.equal(formatDuration(7_200_000), "2h 0m");
});
});
describe("padRight", () => {
it("pads plain text to width", () => {
const result = padRight("abc", 6);
assert.equal(result, "abc ");
});
it("does not pad when text fills width", () => {
const result = padRight("abcdef", 6);
assert.equal(result, "abcdef");
});
it("does not pad when text exceeds width", () => {
const result = padRight("abcdefgh", 6);
assert.equal(result, "abcdefgh");
});
});
describe("joinColumns", () => {
it("joins left and right with spacing", () => {
const result = joinColumns("left", "right", 20);
assert.equal(result.length, 20);
assert.ok(result.startsWith("left"));
assert.ok(result.endsWith("right"));
});
it("truncates when content overflows", () => {
const result = joinColumns("a".repeat(20), "b".repeat(20), 30);
// Should be truncated to 30 chars
assert.ok(result.length <= 30);
});
});
describe("centerLine", () => {
it("centers text within width", () => {
const result = centerLine("hi", 10);
assert.equal(result, " hi");
});
it("truncates when content exceeds width", () => {
const result = centerLine("abcdefgh", 4);
assert.ok(result.length <= 4);
});
});
describe("fitColumns", () => {
it("joins parts that fit", () => {
const result = fitColumns(["aaa", "bbb", "ccc"], 20);
assert.ok(result.includes("aaa"));
assert.ok(result.includes("bbb"));
assert.ok(result.includes("ccc"));
});
it("drops parts that overflow", () => {
const result = fitColumns(["aaa", "bbb", "ccc"], 10);
assert.ok(result.includes("aaa"));
// May or may not include bbb depending on separator width
});
it("returns empty string for empty array", () => {
assert.equal(fitColumns([], 80), "");
});
it("filters out empty strings", () => {
const result = fitColumns(["aaa", "", "bbb"], 80);
assert.ok(result.includes("aaa"));
assert.ok(result.includes("bbb"));
});
});
describe("sparkline", () => {
it("returns empty string for empty array", () => {
assert.equal(sparkline([]), "");
});
it("renders all lowest blocks for all-zero values", () => {
const result = sparkline([0, 0, 0]);
assert.equal(result.length, 3);
// All chars should be the same (lowest block)
assert.equal(result[0], result[1]);
assert.equal(result[1], result[2]);
});
it("renders highest block for max value", () => {
const result = sparkline([0, 10, 5]);
assert.equal(result.length, 3);
// Middle should be highest block (█)
assert.equal(result[1], "\u2588");
});
it("handles single value", () => {
const result = sparkline([42]);
assert.equal(result.length, 1);
assert.equal(result, "\u2588");
});
it("handles large arrays without stack overflow", () => {
const largeArray = new Array(100_000).fill(0).map((_, i) => i);
const result = sparkline(largeArray);
assert.equal(result.length, 100_000);
});
});
describe("stripAnsi", () => {
it("strips ANSI escape sequences", () => {
const result = stripAnsi("\x1b[31mred\x1b[0m text");
assert.equal(result, "red text");
});
it("returns plain text unchanged", () => {
assert.equal(stripAnsi("plain text"), "plain text");
});
it("strips multiple escape sequences", () => {
const result = stripAnsi("\x1b[1m\x1b[32mbold green\x1b[0m");
assert.equal(result, "bold green");
});
it("handles empty string", () => {
assert.equal(stripAnsi(""), "");
});
});